Files
ATVLED/QuadLightsV3.ino
T

1465 lines
46 KiB
Arduino
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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
constexpr bool KEEPALIVE_ENABLE = true;
constexpr int KEEPALIVE_TIMEOUT = 45000; // ms
constexpr int LEDS_TAIL_PIN = PA6;
constexpr int LEDS_TAIL_LENGTH = 10;
constexpr int LEDS_TAIL_BRIGHTNESS = 255;
constexpr int LEDS_TAIL_SIDE_LENGTH = 2;
constexpr int LEDS_TAIL_SIDE_MARGIN = 2;
constexpr int LEDS_LEFT_PIN = PA5;
constexpr int LEDS_RIGHT_PIN = PA7;
/* default configuration right lamp (left from the front)
RING EDGE
31 32 33 34 35 36 37 38 0 1 2 3 4 5 6 7 8 9 10 11
30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12
*/
constexpr int LEDS_TOP_LENGTH = 12;
constexpr int LEDS_BOTTOM_LENGTH = 11;
constexpr int LEDS_RING_LENGTH = 16;
constexpr int LEDS_EDGE_LENGTH = LEDS_TOP_LENGTH + LEDS_BOTTOM_LENGTH;
constexpr int LEDS_HEAD_LENGTH = LEDS_TOP_LENGTH + LEDS_BOTTOM_LENGTH + LEDS_RING_LENGTH;
constexpr int LEDS_HEAD_BRIGHTNESS = 200;
constexpr int LEDS_DASH_PIN = PB0;
constexpr int LEDS_DASH_LENGTH = 2;
constexpr int LEDS_DASH_BRIGHTNESS = 255;
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 unsigned long IMU_RETRY_INTERVAL = 250; // ms, after a supply dip the IMU is usually back quickly
constexpr unsigned long IMU_RESET_TIME = 110; // ms, the IMU needs ~100ms after a reset before it takes configuration
// MPU9250/9255, configured like CrashRecorder but only buffering the accelerometer
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_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_ENABLE = 0x38;
constexpr uint8_t IMU_REG_INT_STATUS = 0x3A;
constexpr uint8_t IMU_REG_ACCEL_XOUT_H = 0x3B;
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 = 0x08;
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_DLPF_184HZ = 0x01;
constexpr uint8_t IMU_ACCEL_16G = 0x18;
constexpr float ACCEL_LSB_PER_G = 2048;
constexpr int IMU_SAMPLE_RATE = 500; // Hz
constexpr int FIFO_SIZE = 512;
constexpr int FIFO_SAMPLE_SIZE = 6; // accel xyz, big-endian
constexpr int FIFO_READ_BATCH = 5; // samples per I2C read, stays within the 32 byte Wire buffer
constexpr unsigned long FIFO_INTERVAL = 10; // ms, the fifo holds ~170ms at 500Hz
constexpr unsigned long IMU_HEALTH_INTERVAL = 1000; // ms
constexpr float IMU_MISALIGNED_DIFFERENCE = 0.5; // g between the fifo stream and a direct read, a shifted stream is ~1.5g off
constexpr int IMU_MISALIGNED_CHECKS = 2; // in a row, so a single check during a crash doesn't count
/* crash detection, from the CrashRecorder recordings of 2026-10-21 (recordings/, tools/crash_dump.py)
speed lost against the direction of travel within 100ms:
normal stops <= 0.19 m/s, handling by hand <= 0.25 m/s (parked, so not armed)
wall hits 0.58 - 1.07 m/s, driving off a threshold into the doorframe 0.51 m/s
the sensor is mounted on its side with +z pointing backward, its offset is removed by the gravity baseline
*/
constexpr float CRASH_SPEED_LOSS = 0.6; // m/s, only proper hits, 0.35-0.45 also triggered on grazes and lighter bumps
constexpr float CRASH_LOG_SPEED_LOSS = 0.2; // m/s, anything above is logged to help tune the threshold
constexpr int CRASH_WINDOW = 50; // samples, 100ms
constexpr unsigned long CRASH_ARMED_AFTER_RELEASE = 1500; // ms, impacts happen with the pedal still down or just released
constexpr unsigned long CRASH_HAZARD_DURATION = 5000; // ms
constexpr float GRAVITY_BASELINE_TIME = 2.0; // s, follows gravity leaking into the forward axis on slopes
constexpr float STANDARD_GRAVITY = 9.81; // m/s2
// per-sensor calibration from the tilt test of 2026-10-21, keep in sync with tools/crash_dump.py
constexpr float ACCEL_OFFSET_X = 0.490; // g, sensor axes
constexpr float ACCEL_OFFSET_Y = -0.277;
constexpr float ACCEL_OFFSET_Z = -0.004;
constexpr float FLAT_FORWARD = -0.060; // g, gravity in the vehicle frame when standing flat
constexpr float FLAT_LEFT = -0.019;
constexpr float FLAT_UP = 0.996;
constexpr unsigned long STARTUP_SWEEP = 500; // ms, the headlight edges sweep on before the normal lights
constexpr unsigned long STARTUP_RING_DELAY = 200; // ms from the start, STARTUP_SWEEP waits for the sweep to complete
constexpr unsigned long STARTUP_RING_FADE = 300; // ms
constexpr unsigned long STARTUP_DURATION = STARTUP_RING_DELAY + STARTUP_RING_FADE > STARTUP_SWEEP
? STARTUP_RING_DELAY + STARTUP_RING_FADE
: STARTUP_SWEEP;
// the top and bottom edge rows start side by side in the same corner, so the first LEDs light up as a bright pair
// they start dimmer, the first at STARTUP_CORNER_LEVEL rising to full over STARTUP_CORNER_LEDS, all full when the sweep completes
constexpr int STARTUP_CORNER_LEDS = 4;
constexpr float STARTUP_CORNER_LEVEL = 0;
// scrambled lights while tilted, an easter egg
constexpr float SCRAMBLE_TILT = 20; // degrees from flat to start
constexpr float SCRAMBLE_TILT_END = 8; // degrees, lower so it doesn't flicker around the threshold
constexpr unsigned long SCRAMBLE_HOLD = 300; // ms above SCRAMBLE_TILT before it starts
constexpr float TILT_FILTER_TIME = 1.0; // s, the motor pulling the quad forward reads like tilt but doesn't last
constexpr int SCRAMBLE_FRAMES = 4; // between changes, at full frame rate it would blur into white
constexpr int SCRAMBLE_CHANGE_PERCENT = 35; // of the pixels changed each time
// the dash shows what crash detection sees, for testing without serial, replacing its normal use
// first pixel white: debug mode is on
// second pixel, by priority: red: IMU problem (read failed, overflow, reconfigured, not found), yellow flash: near miss,
// blue: armed and travelling backward (only expected in reverse), green: armed, off: not armed
constexpr bool DEBUG_DASH = false;
// temporary, for testing by pushing the quad into a wall without the motor:
// detects in both directions without the pedal, and disables the keepalive timeout (hold mode to power off)
constexpr bool DEBUG_CRASH_WITHOUT_PEDAL = false;
// temporary, logs a status line every IMU_HEALTH_INTERVAL to find crash detection dropouts from the log alone
constexpr bool DEBUG_IMU_LOG = false;
constexpr unsigned long DEBUG_NEAR_MISS_DURATION = 500; // ms
constexpr unsigned long DEBUG_IMU_PROBLEM_DURATION = 3000; // ms
constexpr int LED_ON = LOW;
constexpr int LED_OFF = HIGH;
constexpr int PEDAL_ON = HIGH;
constexpr int PEDAL_OFF = LOW;
constexpr int BUCK_ON = HIGH;
constexpr int BUCK_OFF = LOW;
constexpr int ANIMATION_FPS = 30;
constexpr int ANIMATION_INTERVAL = lround(1000 / ANIMATION_FPS);
Button pedal(PIN_PEDAL);
Button pedalForward(PIN_PEDAL_FORWARD);
Button pedalReverse(PIN_PEDAL_REVERSE);
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);
enum Direction { LEFT, RIGHT };
enum Mode {
MODE_HEAD,
MODE_POLICE,
MODE_RAINBOW,
MODE_KITT,
MODE_HAZARD,
MODE_COUNT,
};
Mode activeMode = MODE_HEAD;
// polling stops as soon as the IMU fails, every I2C call to a missing IMU blocks the loop for up to 100ms
enum ImuState { IMU_MISSING, IMU_STARTING, IMU_RUNNING };
// Mode activeMode = MODE_KITT;
uint32_t colorHSV(float hue, float sat = 1.0, float val = 1.0) {
return Adafruit_NeoPixel::ColorHSV(
(uint16_t)(hue / 360.0 * 65535),
(uint8_t)(sat * 255),
(uint8_t)(val * 255)
);
}
const uint32_t COLOR_OFF = ledsTail.Color(0, 0, 0, 0);
const uint32_t COLOR_WHITE = ledsTail.Color(0, 0, 0, 255);
const uint32_t COLOR_WHITE_DIM = ledsTail.Color(0, 0, 0, 100);
const uint32_t COLOR_RED = ledsTail.Color(255, 0, 0, 0);
const uint32_t COLOR_AMBER = colorHSV(10); // easier value fade
const uint32_t COLOR_POLICE_BLUE = ledsTail.Color(0, 0, 255, 0);
const uint32_t COLOR_POLICE_RED = ledsTail.Color(255, 0, 0, 0);
const uint32_t COLOR_GREEN = ledsTail.Color(0, 255, 0, 0);
const uint32_t COLOR_YELLOW = ledsTail.Color(255, 150, 0, 0);
unsigned long animationFrame = 0;
unsigned long stableAnimationFrame = 0;
unsigned long lastFrame = 0;
unsigned long lastPedalPress = millis();
unsigned long lastPedalRelease = 0;
unsigned long lastButtonPress = 0;
bool isReversing = false;
unsigned long indicatorLeftStart = 0;
unsigned long indicatorRightStart = 0;
bool indicatorLeftActive = false;
bool indicatorRightActive = false;
ImuState imuState = IMU_MISSING;
unsigned long imuStateSince = 0;
bool imuMissingReported = false; // only the first failure of an outage is logged
uint8_t imuAddress = 0;
uint8_t imuWhoAmI = 0;
unsigned long lastFifoRead = 0;
unsigned long lastImuHealth = 0;
unsigned long imuSamples = 0; // since the last health check
unsigned long lastImuProblem = 0; // 0 when none yet
unsigned long lastNearMiss = 0;
int16_t lastFifoSample[3] = {}; // raw accel xyz
int imuMisalignedChecks = 0;
float debugSpeedChangeMin = 0; // since the last health check
float debugSpeedChangeMax = 0;
unsigned long startupStart = 0;
bool shuttingDown = false;
unsigned long shutdownStart = 0;
bool travellingBackward = false; // last direction while the pedal was down
bool crashHazardActive = false;
unsigned long crashHazardStart = 0;
float gravityBaseline = 0;
bool gravityBaselineReady = false;
float tiltForward = 0; // g, filtered gravity in the vehicle frame
float tiltLeft = 0;
float tiltUp = 0;
bool tiltReady = false;
bool scrambleActive = false;
bool scrambleTilted = false; // above SCRAMBLE_TILT, waiting for SCRAMBLE_HOLD
unsigned long scrambleTiltedSince = 0;
uint32_t scrambleHeadLeft[LEDS_HEAD_LENGTH];
uint32_t scrambleHeadRight[LEDS_HEAD_LENGTH];
uint32_t scrambleTail[LEDS_TAIL_LENGTH];
uint32_t scrambleDash[LEDS_DASH_LENGTH];
float crashWindow[CRASH_WINDOW] = {};
float crashWindowSum = 0;
int crashWindowIndex = 0;
float crashEventPeak = 0; // largest speed loss of the current event above CRASH_LOG_SPEED_LOSS
bool crashEventTriggered = false;
void writeRegister(uint8_t reg, uint8_t value) {
Wire.beginTransmission(imuAddress);
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 reg) {
uint8_t value = 0;
readRegisters(imuAddress, reg, &value, 1);
return value;
}
void reportImuProblem(const char* message) {
Serial.println(message);
lastImuProblem = max(millis(), 1ul);
}
void startFifo() {
writeRegister(IMU_REG_FIFO_EN, 0);
writeRegister(IMU_REG_USER_CTRL, IMU_USER_CTRL_FIFO_RST);
readRegister(IMU_REG_INT_STATUS); // clears a stale overflow flag
writeRegister(IMU_REG_USER_CTRL, IMU_USER_CTRL_FIFO_EN);
writeRegister(IMU_REG_FIFO_EN, IMU_FIFO_EN_ACCEL);
}
void beginWire() {
// setSCL/SDA have to be called before begin()
Wire.setSCL(PIN_IMU_SCL);
Wire.setSDA(PIN_IMU_SDA);
Wire.begin();
Wire.setClock(400000);
}
// the IMU can reset halfway through a transfer, likely browning out when the motor stalls against a wall, and keep
// holding SDA low, which blocks the bus until power is cut. clocking SCL until it lets go and sending a STOP frees it,
// restarting Wire also clears the I2C peripheral if it got stuck busy
void recoverWire() {
constexpr int PULSE = 5; // us, ~100kHz
constexpr int MAX_CLOCKS = 16;
Wire.end();
pinMode(PIN_IMU_SDA, INPUT_PULLUP);
pinMode(PIN_IMU_SCL, OUTPUT_OPEN_DRAIN);
digitalWrite(PIN_IMU_SCL, HIGH);
delayMicroseconds(PULSE);
for (int clock = 0; clock < MAX_CLOCKS && digitalRead(PIN_IMU_SDA) == LOW; clock++) {
digitalWrite(PIN_IMU_SCL, LOW);
delayMicroseconds(PULSE);
digitalWrite(PIN_IMU_SCL, HIGH);
delayMicroseconds(PULSE);
}
// STOP: SDA rising while SCL is high
pinMode(PIN_IMU_SDA, OUTPUT_OPEN_DRAIN);
digitalWrite(PIN_IMU_SDA, LOW);
delayMicroseconds(PULSE);
digitalWrite(PIN_IMU_SDA, HIGH);
delayMicroseconds(PULSE);
beginWire();
}
void setImuState(ImuState state, unsigned long now) {
imuState = state;
imuStateSince = now;
}
// stops polling until the next reconnect, retrying a failing IMU would only block the loop on I2C timeouts
void imuFailed(const char* message, unsigned long now) {
if (!imuMissingReported) {
reportImuProblem(message);
imuMissingReported = true;
}
setImuState(IMU_MISSING, now);
}
// first half of connecting, configureImu() finishes after IMU_RESET_TIME without blocking the loop
void connectImu(unsigned long now, bool recover = true) {
if (recover) {
recoverWire();
}
imuAddress = 0;
for (const uint8_t address : IMU_ADDRESSES) {
if (readRegisters(address, IMU_REG_WHO_AM_I, &imuWhoAmI, 1)) {
imuAddress = address;
break;
}
}
if (imuAddress == 0) {
imuFailed("IMU NOT FOUND ON PB6/PB7, CRASH DETECTION DISABLED UNTIL IT IS BACK", now);
return;
}
writeRegister(IMU_REG_PWR_MGMT_1, 0x80); // reset
setImuState(IMU_STARTING, now);
}
void configureImu(unsigned long now) {
writeRegister(IMU_REG_PWR_MGMT_1, 0x01); // gyro PLL clock
writeRegister(IMU_REG_CONFIG, IMU_DLPF_184HZ);
writeRegister(IMU_REG_SMPLRT_DIV, 1000 / IMU_SAMPLE_RATE - 1);
writeRegister(IMU_REG_ACCEL_CONFIG, IMU_ACCEL_16G);
writeRegister(IMU_REG_ACCEL_CONFIG2, IMU_DLPF_184HZ);
writeRegister(IMU_REG_INT_ENABLE, IMU_INT_FIFO_OFLOW);
startFifo();
resetCrashMeasure();
imuSamples = 0;
lastImuHealth = now;
imuMissingReported = false;
setImuState(IMU_RUNNING, now);
Serial.print("IMU FOUND AT 0x");
Serial.print(imuAddress, HEX);
Serial.print(", WHO_AM_I 0x");
Serial.println(imuWhoAmI, HEX);
}
// the baseline and window follow whatever the stream delivered, start over after the stream was wrong
void resetCrashMeasure() {
gravityBaselineReady = false;
tiltReady = false;
crashWindowSum = 0;
crashWindowIndex = 0;
for (float& value : crashWindow) {
value = 0;
}
}
// recovers from the IMU resetting itself, e.g. after a supply dip, which would otherwise disable crash detection silently
void checkImuHealth(unsigned long now) {
uint8_t accelConfig = 0;
uint8_t fifoEnabled = 0;
if (!readRegisters(imuAddress, IMU_REG_ACCEL_CONFIG, &accelConfig, 1) || !readRegisters(imuAddress, IMU_REG_FIFO_EN, &fifoEnabled, 1)) {
imuFailed("IMU NOT RESPONDING, RECONNECTING", now);
return;
}
const unsigned long samples = imuSamples;
if (accelConfig != IMU_ACCEL_16G || fifoEnabled != IMU_FIFO_EN_ACCEL) {
imuFailed("IMU LOST ITS CONFIGURATION, RECONNECTING", now);
return;
}
if (imuSamples == 0) {
imuFailed("IMU STOPPED SENDING SAMPLES, RECONNECTING", now);
return;
}
imuSamples = 0;
float streamDifference = -1; // g, largest axis difference, -1 when the direct read failed
// a byte lost without a read error shifts the stream onto another axis, which still looks sane but never shows a crash
// a shift by either one or two axes moves the vertical axis (~1.5g with its offset) where it doesn't belong
uint8_t direct[6];
if (readRegisters(imuAddress, IMU_REG_ACCEL_XOUT_H, direct, 6)) {
bool misaligned = false;
for (int axis = 0; axis < 3; axis++) {
const int16_t directValue = (int16_t)(direct[axis * 2] << 8 | direct[axis * 2 + 1]);
const float difference = abs(directValue - lastFifoSample[axis]) / ACCEL_LSB_PER_G;
streamDifference = max(streamDifference, difference);
misaligned |= difference > IMU_MISALIGNED_DIFFERENCE;
}
imuMisalignedChecks = misaligned ? imuMisalignedChecks + 1 : 0;
if (imuMisalignedChecks >= IMU_MISALIGNED_CHECKS) {
reportImuProblem("IMU FIFO MISALIGNED, RESET");
startFifo();
resetCrashMeasure();
imuMisalignedChecks = 0;
}
}
if (DEBUG_IMU_LOG) {
Serial.print("IMU ");
Serial.print(samples);
Serial.print(" SAMPLES, BASELINE ");
Serial.print(gravityBaseline, 2);
Serial.print(" G, SPEED CHANGE ");
Serial.print(debugSpeedChangeMin, 2);
Serial.print(" / ");
Serial.print(debugSpeedChangeMax, 2);
Serial.print(" M/S, STREAM VS DIRECT ");
Serial.print(streamDifference, 2);
Serial.println(" G");
}
debugSpeedChangeMin = 0;
debugSpeedChangeMax = 0;
}
void triggerCrash(unsigned long now) {
crashHazardActive = true;
crashHazardStart = now;
animationFrame = 0; // start the hazard sweep cleanly
Serial.println("CRASH DETECTED, HAZARD ON");
}
bool isCrashArmed(unsigned long now) {
return DEBUG_CRASH_WITHOUT_PEDAL
|| pedal.read() == Button::PRESSED
|| (lastPedalRelease > 0 && now - lastPedalRelease < CRASH_ARMED_AFTER_RELEASE);
}
void processImuSample(const uint8_t* sample, unsigned long now) {
imuSamples++;
for (int axis = 0; axis < 3; axis++) {
lastFifoSample[axis] = (int16_t)(sample[axis * 2] << 8 | sample[axis * 2 + 1]);
}
// mounted on its side: +x up, +y left, +z backward
const float sampleForward = -(lastFifoSample[2] / ACCEL_LSB_PER_G - ACCEL_OFFSET_Z);
const float sampleLeft = lastFifoSample[1] / ACCEL_LSB_PER_G - ACCEL_OFFSET_Y;
const float sampleUp = lastFifoSample[0] / ACCEL_LSB_PER_G - ACCEL_OFFSET_X;
if (!tiltReady) {
tiltForward = sampleForward;
tiltLeft = sampleLeft;
tiltUp = sampleUp;
tiltReady = true;
}
tiltForward += (sampleForward - tiltForward) / (TILT_FILTER_TIME * IMU_SAMPLE_RATE);
tiltLeft += (sampleLeft - tiltLeft) / (TILT_FILTER_TIME * IMU_SAMPLE_RATE);
tiltUp += (sampleUp - tiltUp) / (TILT_FILTER_TIME * IMU_SAMPLE_RATE);
const int16_t rawZ = lastFifoSample[2];
const float forward = -rawZ / ACCEL_LSB_PER_G;
if (!gravityBaselineReady) {
gravityBaseline = forward;
gravityBaselineReady = true;
}
gravityBaseline += (forward - gravityBaseline) / (GRAVITY_BASELINE_TIME * IMU_SAMPLE_RATE);
crashWindowSum += forward - gravityBaseline - crashWindow[crashWindowIndex];
crashWindow[crashWindowIndex] = forward - gravityBaseline;
crashWindowIndex = (crashWindowIndex + 1) % CRASH_WINDOW;
// recompute every window so float rounding can't accumulate
if (crashWindowIndex == 0) {
crashWindowSum = 0;
for (const float value : crashWindow) {
crashWindowSum += value;
}
}
const float speedChange = crashWindowSum / IMU_SAMPLE_RATE * STANDARD_GRAVITY; // m/s gained forward within the window
debugSpeedChangeMin = min(debugSpeedChangeMin, speedChange);
debugSpeedChangeMax = max(debugSpeedChangeMax, speedChange);
const float speedLoss = DEBUG_CRASH_WITHOUT_PEDAL
? fabsf(speedChange)
: travellingBackward ? speedChange : -speedChange;
if (isCrashArmed(now) && speedLoss > CRASH_LOG_SPEED_LOSS) {
crashEventPeak = max(crashEventPeak, speedLoss);
if (speedLoss >= CRASH_SPEED_LOSS && !crashEventTriggered) {
crashEventTriggered = true;
triggerCrash(now);
}
} else if (crashEventPeak > 0) {
Serial.print(crashEventTriggered ? "CRASH " : "NEAR MISS ");
Serial.print(crashEventPeak, 2);
Serial.println(travellingBackward ? " M/S LOST REVERSING" : " M/S LOST");
if (!crashEventTriggered) {
lastNearMiss = now;
}
crashEventPeak = 0;
crashEventTriggered = false;
}
}
void readFifo(unsigned long now) {
uint8_t status = 0;
uint8_t countBytes[2] = {0, 0};
if (!readRegisters(imuAddress, IMU_REG_INT_STATUS, &status, 1) || !readRegisters(imuAddress, IMU_REG_FIFO_COUNT_H, countBytes, 2)) {
imuFailed("IMU NOT RESPONDING, RECONNECTING", now);
return;
}
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) {
reportImuProblem("IMU FIFO OVERFLOW, RESET");
startFifo();
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);
// a partial read leaves the fifo misaligned, which silently shifts every following sample to another axis
if (!readRegisters(imuAddress, IMU_REG_FIFO_R_W, buffer, batch * FIFO_SAMPLE_SIZE)) {
imuFailed("IMU READ FAILED, RECONNECTING", now);
return;
}
for (int i = 0; i < batch; i++) {
processImuSample(buffer + i * FIFO_SAMPLE_SIZE, now);
}
}
}
void setup() {
// initialize digital pin LED_BUILTIN as an output.
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);
pinMode(PIN_BUTTON_MODE, INPUT_PULLUP);
pinMode(PIN_BUTTON_LEFT, INPUT_PULLUP);
pinMode(PIN_BUTTON_RIGHT, INPUT_PULLUP);
// digitalWrite(LED_BUILTIN, LED_ON);
digitalWrite(PIN_ALIVE, HIGH);
digitalWrite(PIN_BUCK, BUCK_ON);
digitalWrite(LED_BUILTIN, LED_OFF);
ledsTail.setBrightness(LEDS_TAIL_BRIGHTNESS);
ledsHeadLeft.setBrightness(LEDS_HEAD_BRIGHTNESS);
ledsHeadRight.setBrightness(LEDS_HEAD_BRIGHTNESS);
ledsDash.setBrightness(LEDS_DASH_BRIGHTNESS);
ledsTail.begin();
ledsHeadLeft.begin();
ledsHeadRight.begin();
ledsDash.begin();
pedal.begin();
pedalForward.begin();
pedalReverse.begin();
buttonMode.begin();
buttonLeft.begin();
buttonRight.begin();
Serial.begin(9600);
randomSeed(micros() ^ HAL_GetUIDw0() ^ HAL_GetUIDw1());
beginWire();
connectImu(millis(), false);
ledsTail.clear();
ledsHeadLeft.clear();
ledsHeadRight.clear();
ledsDash.clear();
startupStart = millis();
}
void animateHeadlights() {
ledsHeadLeft.fill(COLOR_WHITE);
ledsHeadRight.fill(COLOR_WHITE);
}
void animateTailLight() {
ledsTail.fill(COLOR_OFF, 0, LEDS_TAIL_LENGTH);
ledsTail.fill(COLOR_RED, LEDS_TAIL_SIDE_LENGTH, LEDS_TAIL_LENGTH - LEDS_TAIL_SIDE_LENGTH * 2);
}
void animateRightTailSide(uint32_t color, bool withMargin = true) {
ledsTail.fill(color, 0, LEDS_TAIL_SIDE_LENGTH);
if (withMargin) {
ledsTail.fill(COLOR_OFF, LEDS_TAIL_SIDE_LENGTH, LEDS_TAIL_SIDE_MARGIN);
}
}
void animateLeftTailSide(uint32_t color, bool withMargin = true) {
ledsTail.fill(color, LEDS_TAIL_LENGTH - LEDS_TAIL_SIDE_LENGTH, LEDS_TAIL_SIDE_LENGTH);
if (withMargin) {
ledsTail.fill(COLOR_OFF, LEDS_TAIL_LENGTH - LEDS_TAIL_SIDE_LENGTH - LEDS_TAIL_SIDE_MARGIN, LEDS_TAIL_SIDE_MARGIN);
}
}
void animateDash() {
ledsDash.fill(COLOR_GREEN);
}
void animateReverse() {
ledsTail.fill(COLOR_WHITE);
}
void animateBrake() {
animateLeftTailSide(COLOR_RED, false);
animateRightTailSide(COLOR_RED, false);
}
constexpr int INDICATOR_DURATION = 3200; // total ms
constexpr int INDICATOR_HOLD_FRAMES = 15;
constexpr int INDICATOR_OFF_FRAMES = 20;
constexpr float INDICATOR_SPEED = 2;
// shutdown blinks like locking a car with the key fob, on, off and on again, timed like a normal indicator blink
// the second blink stays on and fades out with everything else
constexpr unsigned long SHUTDOWN_BLINK_ON = (LEDS_TOP_LENGTH + INDICATOR_HOLD_FRAMES) / INDICATOR_SPEED * ANIMATION_INTERVAL; // ms, sweep and hold
constexpr unsigned long SHUTDOWN_BLINK_CYCLE = (LEDS_EDGE_LENGTH + INDICATOR_HOLD_FRAMES + INDICATOR_OFF_FRAMES) / INDICATOR_SPEED * ANIMATION_INTERVAL; // ms
constexpr unsigned long SHUTDOWN_FADE = 500; // ms
constexpr unsigned long SHUTDOWN_FADE_START = SHUTDOWN_BLINK_CYCLE + SHUTDOWN_BLINK_ON;
constexpr unsigned long SHUTDOWN_DURATION = SHUTDOWN_FADE_START + SHUTDOWN_FADE;
void animateIndicator(Direction direction) {
Adafruit_NeoPixel& ledsTarget = direction == LEFT
? ledsHeadLeft
: ledsHeadRight;
const long indicatorFrame = lround(animationFrame * INDICATOR_SPEED) % (LEDS_EDGE_LENGTH + INDICATOR_HOLD_FRAMES + INDICATOR_OFF_FRAMES);
const long sweepEnd = LEDS_TOP_LENGTH;
const long holdEnd = LEDS_TOP_LENGTH + INDICATOR_HOLD_FRAMES;
for (int ledIndex = LEDS_TOP_LENGTH - 1; ledIndex >= 0; ledIndex--) {
if (indicatorFrame < sweepEnd) {
// SWEEP
bool isLit = ledIndex >= (LEDS_TOP_LENGTH - 1 - indicatorFrame);
ledsTarget.setPixelColor(ledIndex, isLit ? COLOR_AMBER : COLOR_OFF);
if (ledIndex > 0) {
ledsTarget.setPixelColor(LEDS_EDGE_LENGTH - ledIndex, isLit ? COLOR_AMBER : COLOR_OFF);
}
} else if (indicatorFrame < holdEnd) {
// HOLD
ledsTarget.setPixelColor(ledIndex, COLOR_AMBER);
if (ledIndex > 0) {
ledsTarget.setPixelColor(LEDS_EDGE_LENGTH - ledIndex, COLOR_AMBER);
}
} else {
// OFF
ledsTarget.fill(COLOR_OFF, 0, LEDS_EDGE_LENGTH);
}
}
void (*animateTailSide)(uint32_t, bool) = direction == LEFT
? animateLeftTailSide
: animateRightTailSide;
const int dashIndex = direction == LEFT
? 1
: 0;
if (indicatorFrame < sweepEnd) {
const int colorAmberFaded = colorHSV(10, 1, (float)indicatorFrame / sweepEnd);
animateTailSide(colorAmberFaded, true);
ledsDash.setPixelColor(dashIndex, colorAmberFaded);
} else if (indicatorFrame < holdEnd) {
animateTailSide(COLOR_AMBER, true);
ledsDash.setPixelColor(dashIndex, COLOR_AMBER);
} else {
animateTailSide(COLOR_OFF, true);
ledsDash.setPixelColor(dashIndex, COLOR_OFF);
}
}
constexpr float POLICE_SPEED = 0.75;
enum PoliceStates { POLICE_LEFT, POLICE_RIGHT, POLICE_OFF };
const PoliceStates policeBeaconFrames[12] = {
POLICE_LEFT,
POLICE_OFF,
POLICE_LEFT,
POLICE_OFF,
POLICE_OFF,
POLICE_OFF,
POLICE_RIGHT,
POLICE_OFF,
POLICE_RIGHT,
POLICE_OFF,
POLICE_OFF,
POLICE_OFF,
};
void animatePolice() {
const long policeFrame = lround(animationFrame * POLICE_SPEED) % 12;
if (policeBeaconFrames[policeFrame] == POLICE_LEFT) {
ledsHeadLeft.fill(COLOR_POLICE_BLUE, 0, LEDS_EDGE_LENGTH);
ledsHeadRight.fill(COLOR_OFF, 0, LEDS_EDGE_LENGTH);
// deliberately inverted from edge lights for a more alternating effect
animateLeftTailSide(COLOR_OFF);
animateRightTailSide(COLOR_POLICE_BLUE);
ledsDash.setPixelColor(1, COLOR_POLICE_BLUE);
ledsDash.setPixelColor(0, COLOR_OFF);
} else if (policeBeaconFrames[policeFrame] == POLICE_RIGHT) {
ledsHeadLeft.fill(COLOR_OFF, 0, LEDS_EDGE_LENGTH);
ledsHeadRight.fill(COLOR_POLICE_BLUE, 0, LEDS_EDGE_LENGTH);
animateLeftTailSide(COLOR_POLICE_BLUE);
animateRightTailSide(COLOR_OFF);
ledsDash.setPixelColor(1, COLOR_OFF);
ledsDash.setPixelColor(0, COLOR_POLICE_BLUE);
} else {
ledsHeadLeft.fill(COLOR_OFF, 0, LEDS_EDGE_LENGTH);
ledsHeadRight.fill(COLOR_OFF, 0, LEDS_EDGE_LENGTH);
animateLeftTailSide(COLOR_OFF);
animateRightTailSide(COLOR_OFF);
ledsDash.setPixelColor(1, COLOR_OFF);
ledsDash.setPixelColor(0, COLOR_OFF);
}
if (policeFrame < 6) {
ledsHeadLeft.fill(COLOR_WHITE_DIM, LEDS_EDGE_LENGTH, LEDS_RING_LENGTH);
ledsHeadRight.fill(COLOR_WHITE, LEDS_EDGE_LENGTH, LEDS_RING_LENGTH);
} else {
ledsHeadLeft.fill(COLOR_WHITE, LEDS_EDGE_LENGTH, LEDS_RING_LENGTH);
ledsHeadRight.fill(COLOR_WHITE_DIM, LEDS_EDGE_LENGTH, LEDS_RING_LENGTH);
}
}
constexpr float RAINBOW_SPEED = 2.0;
constexpr float RAINBOW_DENSITY = 10.0;
void animateRainbow() {
// EDGES, flowing color
for (int ledIndex = LEDS_TOP_LENGTH - 1; ledIndex >= 0; ledIndex--) {
const uint32_t pixelColor = colorHSV((stableAnimationFrame * RAINBOW_SPEED) + (ledIndex * RAINBOW_DENSITY));
ledsHeadLeft.setPixelColor(ledIndex, pixelColor);
ledsHeadLeft.setPixelColor(LEDS_EDGE_LENGTH - ledIndex, pixelColor);
ledsHeadRight.setPixelColor(ledIndex, pixelColor);
ledsHeadRight.setPixelColor(LEDS_EDGE_LENGTH - ledIndex, pixelColor);
}
// RINGS, single color
ledsHeadLeft.fill(colorHSV(stableAnimationFrame * RAINBOW_SPEED), LEDS_EDGE_LENGTH, LEDS_RING_LENGTH);
ledsHeadRight.fill(colorHSV(stableAnimationFrame * RAINBOW_SPEED), LEDS_EDGE_LENGTH, LEDS_RING_LENGTH);
for (int ledIndex = 0; ledIndex < LEDS_TAIL_LENGTH; ledIndex++) {
const int distFromCenter = abs(ledIndex * 2 - (LEDS_TAIL_LENGTH - 1));
const uint32_t pixelColor = colorHSV((stableAnimationFrame * RAINBOW_SPEED) - (distFromCenter * RAINBOW_DENSITY * 2));
ledsTail.setPixelColor(ledIndex, pixelColor);
}
ledsDash.setPixelColor(1, colorHSV(stableAnimationFrame * RAINBOW_SPEED));
ledsDash.setPixelColor(0, colorHSV(stableAnimationFrame * RAINBOW_SPEED + 90));
}
constexpr float KITT_SPEED = 0.5;
constexpr int KITT_TRAIL = 4;
constexpr float KITT_TAIL_SPEED = 3.0f;
int kittScanPos(int vPos) {
if (vPos < LEDS_RING_LENGTH) {
// first ring — reversed so it flows naturally into the edge
return LEDS_EDGE_LENGTH + (LEDS_RING_LENGTH - 1 - vPos);
} else if (vPos < LEDS_RING_LENGTH + LEDS_EDGE_LENGTH) {
// edge forward
return vPos - LEDS_RING_LENGTH;
} else if (vPos < 2 * LEDS_RING_LENGTH + LEDS_EDGE_LENGTH) {
// second ring
return LEDS_EDGE_LENGTH + (vPos - LEDS_RING_LENGTH - LEDS_EDGE_LENGTH);
} else {
// edge backward
return LEDS_EDGE_LENGTH - 1 - (vPos - 2 * LEDS_RING_LENGTH - LEDS_EDGE_LENGTH);
}
}
void animateKitt() {
const long kittFrame = lround(stableAnimationFrame * KITT_SPEED);
const int cycleLength = 2 * LEDS_EDGE_LENGTH + 2 * LEDS_RING_LENGTH;
const int vPos = kittFrame % cycleLength;
ledsHeadLeft.fill(COLOR_OFF);
ledsHeadRight.fill(COLOR_OFF);
// trail behind
for (int t = 0; t < KITT_TRAIL; t++) {
const int pastVPos = ((vPos - t) % cycleLength + cycleLength) % cycleLength;
const int scanPos = kittScanPos(pastVPos);
const uint32_t color = colorHSV(0, 1, 1.0f - (float)t / KITT_TRAIL);
ledsHeadLeft.setPixelColor(scanPos, color);
ledsHeadRight.setPixelColor(scanPos, color);
}
// lead ahead — shorter and dimmer than trail
constexpr int KITT_LEAD = 2;
for (int t = 1; t <= KITT_LEAD; t++) {
const int futureVPos = ((vPos + t) % cycleLength + cycleLength) % cycleLength;
const int scanPos = kittScanPos(futureVPos);
const uint32_t color = colorHSV(0, 1, 0.3f * (1.0f - (float)t / KITT_LEAD));
ledsHeadLeft.setPixelColor(scanPos, color);
ledsHeadRight.setPixelColor(scanPos, color);
}
const int scanPos = kittScanPos(vPos);
// normalized cycle (0–1), sped up
float tailCycle = (float)vPos * KITT_TAIL_SPEED / cycleLength;
tailCycle -= (int)tailCycle; // wrap to 0–1
// triangle wave: 0 → 1 → 0 (KITT bounce)
float tailT = tailCycle * 2.0f;
if (tailT > 1.0f) tailT = 2.0f - tailT;
// optional smoothing (gives that analog feel)
tailT = tailT * tailT * (3.0f - 2.0f * tailT); // smoothstep
// map to LED index
const int tailPos = lround(tailT * (LEDS_TAIL_LENGTH - 1));
// draw tail with falloff
for (int i = 0; i < LEDS_TAIL_LENGTH; i++) {
const int dist = abs(i - tailPos);
float brightness = 0.0f;
if (dist < KITT_TRAIL) {
brightness = 1.0f - (float)dist / KITT_TRAIL;
}
ledsTail.setPixelColor(i, colorHSV(0, 1, brightness));
}
ledsDash.fill(COLOR_OFF);
float speed = 3.0;
float dashCycle = (float)vPos * speed / cycleLength;
dashCycle -= (int)dashCycle; // keep it in 0–1
// triangle wave: 0→1→0 over the cycle
float t = dashCycle * 2.0;
if (t > 1.0) t = 2.0 - t;
ledsDash.setPixelColor(0, colorHSV(0, 1, t));
ledsDash.setPixelColor(1, colorHSV(0, 1, 1.0 - t));
}
void animateDebugDash(unsigned long now) {
uint32_t status = COLOR_OFF;
if (lastImuProblem > 0 && now - lastImuProblem < DEBUG_IMU_PROBLEM_DURATION) {
status = COLOR_RED;
} else if (lastNearMiss > 0 && now - lastNearMiss < DEBUG_NEAR_MISS_DURATION) {
status = COLOR_YELLOW;
} else if (isCrashArmed(now)) {
status = travellingBackward ? COLOR_POLICE_BLUE : COLOR_GREEN;
}
ledsDash.setPixelColor(0, COLOR_WHITE);
ledsDash.setPixelColor(1, status);
}
// degrees between gravity now and gravity when standing flat, 0 without the IMU
float tiltAngle() {
if (!tiltReady || imuState != IMU_RUNNING) {
return 0;
}
const float dot = tiltForward * FLAT_FORWARD + tiltLeft * FLAT_LEFT + tiltUp * FLAT_UP;
const float lengths = sqrtf(tiltForward * tiltForward + tiltLeft * tiltLeft + tiltUp * tiltUp)
* sqrtf(FLAT_FORWARD * FLAT_FORWARD + FLAT_LEFT * FLAT_LEFT + FLAT_UP * FLAT_UP);
if (lengths == 0) {
return 0;
}
return degrees(acosf(constrain(dot / lengths, -1.0f, 1.0f)));
}
void updateScramble(unsigned long now) {
const float tilt = tiltAngle();
if (scrambleActive) {
if (tilt < SCRAMBLE_TILT_END) {
scrambleActive = false;
Serial.println("LEVEL AGAIN, UNSCRAMBLED");
}
return;
}
if (tilt <= SCRAMBLE_TILT) {
scrambleTilted = false;
} else if (!scrambleTilted) {
scrambleTilted = true;
scrambleTiltedSince = now;
} else if (now - scrambleTiltedSince >= SCRAMBLE_HOLD) {
scrambleActive = true;
scrambleTilted = false;
Serial.print("TILTED ");
Serial.print(tilt, 0);
Serial.println(" DEGREES, SCRAMBLED");
}
}
// saturated and far apart on the colour wheel, random hues and brightness blended into pastel
const float SCRAMBLE_HUES[] = {0, 60, 120, 180, 240, 300};
void scramblePixels(uint32_t* pixels, int length, bool all) {
if (all) {
for (int i = 0; i < length; i++) {
pixels[i] = COLOR_OFF;
}
}
for (int i = 0; i < length; i++) {
if (!all && random(100) >= SCRAMBLE_CHANGE_PERCENT) {
continue;
}
// lit pixels always get a dark neighbour, so the colours don't blend
const bool neighbourLit = (i > 0 && pixels[i - 1] != COLOR_OFF)
|| (i < length - 1 && pixels[i + 1] != COLOR_OFF);
pixels[i] = neighbourLit || random(2) == 0
? COLOR_OFF
: colorHSV(SCRAMBLE_HUES[random(sizeof(SCRAMBLE_HUES) / sizeof(SCRAMBLE_HUES[0]))]);
}
}
void drawPixels(Adafruit_NeoPixel& leds, const uint32_t* pixels, int length) {
for (int i = 0; i < length; i++) {
leds.setPixelColor(i, pixels[i]);
}
}
void animateScramble(bool start) {
if (start || stableAnimationFrame % SCRAMBLE_FRAMES == 0) {
scramblePixels(scrambleHeadLeft, LEDS_HEAD_LENGTH, start);
scramblePixels(scrambleHeadRight, LEDS_HEAD_LENGTH, start);
scramblePixels(scrambleTail, LEDS_TAIL_LENGTH, start);
scramblePixels(scrambleDash, LEDS_DASH_LENGTH, start);
}
drawPixels(ledsHeadLeft, scrambleHeadLeft, LEDS_HEAD_LENGTH);
drawPixels(ledsHeadRight, scrambleHeadRight, LEDS_HEAD_LENGTH);
drawPixels(ledsTail, scrambleTail, LEDS_TAIL_LENGTH);
drawPixels(ledsDash, scrambleDash, LEDS_DASH_LENGTH);
}
// LED output looks much brighter than its level at the dark end, a linear fade seems done halfway through
float perceived(float level) {
return Adafruit_NeoPixel::gamma8(level * 255) / 255.0f;
}
// edges sweep on in the same direction as the indicator sweep, the leading LED fading in so it moves smoothly, and the
// tail light sweeps from the centre outward and the dash fades in over the whole startup, the rings fade in from STARTUP_RING_DELAY
void animateStartup(unsigned long now) {
const unsigned long elapsed = now - startupStart;
const float progress = min(1.0f, (float)elapsed / STARTUP_SWEEP);
const float ringLevel = elapsed < STARTUP_RING_DELAY ? 0 : min(1.0f, (float)(elapsed - STARTUP_RING_DELAY) / STARTUP_RING_FADE);
const uint32_t ringColor = ledsHeadLeft.Color(0, 0, 0, 255 * perceived(ringLevel));
// the tail and dash span the whole startup, so they finish when the normal lights take over
const float durationProgress = min(1.0f, (float)elapsed / STARTUP_DURATION);
constexpr int TAIL_PAIRS = (LEDS_TAIL_LENGTH - LEDS_TAIL_SIDE_LENGTH * 2) / 2;
ledsTail.clear();
for (int ledIndex = LEDS_TAIL_SIDE_LENGTH; ledIndex < LEDS_TAIL_LENGTH - LEDS_TAIL_SIDE_LENGTH; ledIndex++) {
const int pair = abs(ledIndex * 2 - (LEDS_TAIL_LENGTH - 1)) / 2; // 0 in the centre
const float level = constrain(durationProgress * TAIL_PAIRS - pair, 0.0f, 1.0f);
ledsTail.setPixelColor(ledIndex, ledsTail.Color(255 * perceived(level), 0, 0, 0));
}
ledsDash.fill(ledsDash.Color(0, 255 * perceived(durationProgress), 0, 0));
for (Adafruit_NeoPixel* leds : {&ledsHeadLeft, &ledsHeadRight}) {
leds->clear();
leds->fill(ringColor, LEDS_EDGE_LENGTH, LEDS_RING_LENGTH);
for (int ledIndex = LEDS_TOP_LENGTH - 1; ledIndex >= 0; ledIndex--) {
const int position = LEDS_TOP_LENGTH - 1 - ledIndex; // 0 is reached first
const float sweepLevel = constrain(progress * LEDS_TOP_LENGTH - position, 0.0f, 1.0f);
const float cornerStart = position < STARTUP_CORNER_LEDS
? STARTUP_CORNER_LEVEL + (1 - STARTUP_CORNER_LEVEL) * position / STARTUP_CORNER_LEDS
: 1;
const float cornerLevel = cornerStart + (1 - cornerStart) * progress;
const uint32_t color = leds->Color(0, 0, 0, 255 * perceived(sweepLevel * cornerLevel));
leds->setPixelColor(ledIndex, color);
if (ledIndex > 0) {
leds->setPixelColor(LEDS_EDGE_LENGTH - ledIndex, color);
}
}
}
}
void fadeLeds(Adafruit_NeoPixel& leds, float level) {
for (uint16_t i = 0; i < leds.numPixels(); i++) {
const uint32_t color = leds.getPixelColor(i);
leds.setPixelColor(i, leds.Color(
(uint8_t)(color >> 16 & 0xFF) * level,
(uint8_t)(color >> 8 & 0xFF) * level,
(uint8_t)(color & 0xFF) * level,
(uint8_t)(color >> 24 & 0xFF) * level
));
}
}
// one amber blink like locking a car, the second stays on and fades out with the other lights
// only the indicator areas blink, like the hazard lights, the rings and tail light keep showing as normal
void animateShutdown(unsigned long now) {
const unsigned long elapsed = now - shutdownStart;
const bool indicatorsOn = elapsed < SHUTDOWN_BLINK_ON || elapsed >= SHUTDOWN_BLINK_CYCLE;
const uint32_t indicatorColor = indicatorsOn ? COLOR_AMBER : COLOR_OFF;
ledsHeadLeft.fill(indicatorColor, 0, LEDS_EDGE_LENGTH);
ledsHeadRight.fill(indicatorColor, 0, LEDS_EDGE_LENGTH);
animateLeftTailSide(indicatorColor, true);
animateRightTailSide(indicatorColor, true);
ledsDash.fill(indicatorColor);
if (elapsed < SHUTDOWN_FADE_START) {
return;
}
const float level = perceived(1 - min(1.0f, (float)(elapsed - SHUTDOWN_FADE_START) / SHUTDOWN_FADE));
fadeLeds(ledsHeadLeft, level);
fadeLeds(ledsHeadRight, level);
fadeLeds(ledsTail, level);
fadeLeds(ledsDash, level);
}
void animate(long now) {
if ((unsigned long)now - startupStart < STARTUP_DURATION) {
animateStartup(now);
return;
}
// always animate headlights, let other modes override
animateHeadlights();
animateTailLight();
animateDash();
if (isReversing) {
animateReverse();
}
if (activeMode == MODE_POLICE) {
animatePolice();
}
if (activeMode == MODE_HAZARD) {
animateIndicator(LEFT);
animateIndicator(RIGHT);
}
if (activeMode == MODE_KITT) {
animateKitt();
}
// lastPedalRelease is 0 until the first release, prevents brake lights right after boot
if (lastPedalRelease > 0 && now - lastPedalRelease < 1500) {
animateBrake();
}
if (activeMode == MODE_RAINBOW) {
animateRainbow();
}
// we need the boolean to prevent the indicators from turning on right after boot
if (indicatorLeftActive && now - indicatorLeftStart < INDICATOR_DURATION) {
animateIndicator(LEFT);
}
if (indicatorRightActive && now - indicatorRightStart < INDICATOR_DURATION) {
animateIndicator(RIGHT);
}
const bool wasScrambled = scrambleActive;
updateScramble(now);
// overrides every mode, only the crash hazard goes on top so a rollover still warns
if (scrambleActive) {
animateScramble(!wasScrambled);
}
// drawn last so it overrides every mode
if (crashHazardActive && now - crashHazardStart < CRASH_HAZARD_DURATION) {
animateIndicator(LEFT);
animateIndicator(RIGHT);
} else {
crashHazardActive = false;
}
if (DEBUG_DASH) {
animateDebugDash(now);
}
if (shuttingDown) {
animateShutdown(now);
}
}
// any button dismisses the crash hazard, that press does nothing else
bool dismissCrashHazard() {
if (!crashHazardActive) {
return false;
}
crashHazardActive = false;
Serial.println("CRASH HAZARD DISMISSED");
return true;
}
void render() {
ledsTail.show();
ledsHeadLeft.show();
ledsHeadRight.show();
ledsDash.show();
}
bool isOff = false;
unsigned long modePressStart = 0;
bool modePressed = false;
// the lights play the shutdown animation first, powerOff() follows after SHUTDOWN_DURATION
void beginShutdown(unsigned long now) {
if (isOff || shuttingDown) {
return;
}
shuttingDown = true;
shutdownStart = now;
modePressed = false; // releasing the long press shouldn't switch mode
Serial.println("SHUTTING DOWN");
}
// any press during the shutdown animation keeps it on, that press does nothing else
bool cancelShutdown() {
if (!shuttingDown) {
return false;
}
shuttingDown = false;
Serial.println("SHUTDOWN CANCELLED");
return true;
}
void powerOff() {
// when STLink or USB prevents full power cut, loop keeps running, only power off once
if (isOff) {
return;
}
shuttingDown = false;
Serial.println("BUCK OFF");
digitalWrite(PIN_BUCK, BUCK_OFF);
delay(100);
Serial.println("SELF OFF, GOODBYE");
digitalWrite(PIN_ALIVE, LOW);
isOff = true;
}
// only used when STLink or USB prevents full power cut
void restart() {
Serial.println("RESTARTING");
lastFrame = millis();
startupStart = lastFrame;
animationFrame = 0;
stableAnimationFrame = 0;
isOff = false;
digitalWrite(PIN_BUCK, BUCK_ON);
}
bool indicatorCombo = false; // both indicator buttons pressed, until both are released
Mode modeBeforeHazard = MODE_HEAD;
void toggleHazardMode() {
if (activeMode == MODE_HAZARD) {
activeMode = modeBeforeHazard;
} else {
modeBeforeHazard = activeMode;
activeMode = MODE_HAZARD;
}
animationFrame = 0; // ensures animations start cleanly instead of halfway
Serial.print("BUTTONS LEFT AND RIGHT, MODE ");
Serial.println(activeMode);
}
void handleIndicatorPress(Direction direction, unsigned long now) {
if (isOff) {
restart();
}
lastButtonPress = now;
if (cancelShutdown() || dismissCrashHazard() || indicatorCombo) {
return;
}
// both buttons are never pressed at exactly the same time, so the first one already started its indicator
Button& otherButton = direction == LEFT ? buttonRight : buttonLeft;
if (otherButton.read() == Button::PRESSED) {
indicatorCombo = true;
indicatorLeftActive = false;
indicatorRightActive = false;
toggleHazardMode();
return;
}
animationFrame = 0;
indicatorLeftActive = direction == LEFT;
indicatorLeftStart = direction == LEFT ? now : 0;
indicatorRightActive = direction == RIGHT;
indicatorRightStart = direction == RIGHT ? now : 0;
Serial.println(direction == LEFT ? "BUTTON LEFT" : "BUTTON RIGHT");
}
// the loop function runs over and over again forever
void loop() {
const unsigned long now = millis();
if (buttonMode.pressed()) {
if (isOff) {
// wake only, this press shouldn't switch mode on release or count towards a shutdown hold
restart();
} else if (cancelShutdown() || dismissCrashHazard()) {
// same for cancelling and dismissing
} else {
modePressed = true;
modePressStart = now;
}
lastButtonPress = now;
}
// switch mode on release, so a long press for shutdown doesn't switch mode first
if (buttonMode.released()) {
if (modePressed && !isOff) {
activeMode = (Mode)((activeMode + 1) % MODE_COUNT);
animationFrame = 0; // ensures animations start cleanly instead of halfway
Serial.print("BUTTON MODE ");
Serial.println(activeMode);
}
modePressed = false;
}
if (modePressed && now - modePressStart > 4000) {
beginShutdown(now);
}
if (buttonLeft.pressed()) {
handleIndicatorPress(LEFT, now);
}
if (buttonRight.pressed()) {
handleIndicatorPress(RIGHT, now);
}
if (indicatorCombo && buttonLeft.read() != Button::PRESSED && buttonRight.read() != Button::PRESSED) {
indicatorCombo = false;
}
// only the pedal counts as activity, forward and reverse inputs can read active while parked
if (pedal.read() == Button::PRESSED) {
lastPedalPress = now;
cancelShutdown();
if (isOff) {
restart();
}
}
if (pedal.pressed()) {
Serial.println("PEDAL PRESSED");
}
if (pedal.released()) {
lastPedalRelease = now;
Serial.println("PEDAL RELEASED");
}
const bool wasReversing = isReversing;
isReversing = pedalReverse.read() == Button::PRESSED && pedalForward.read() != Button::PRESSED;
if (isReversing != wasReversing) {
Serial.println(isReversing ? "REVERSE ON" : "REVERSE OFF");
}
// parked in reverse both inputs read active, so the direction only counts while driving
if (pedal.read() == Button::PRESSED) {
travellingBackward = isReversing;
}
if (!isOff && imuState == IMU_RUNNING && now - lastFifoRead >= FIFO_INTERVAL) {
lastFifoRead = now;
readFifo(now);
}
if (!isOff && imuState == IMU_RUNNING && now - lastImuHealth >= IMU_HEALTH_INTERVAL) {
lastImuHealth = now;
checkImuHealth(now);
}
if (imuState == IMU_STARTING && now - imuStateSince >= IMU_RESET_TIME) {
configureImu(now);
}
// retry while missing, so a supply dip or reseating a wire doesn't need a reboot
if (imuState == IMU_MISSING && now - imuStateSince >= IMU_RETRY_INTERVAL) {
connectImu(now);
}
if (!isOff && now - lastFrame >= ANIMATION_INTERVAL) {
lastFrame += ANIMATION_INTERVAL;
animationFrame += 1;
stableAnimationFrame += 1;
animate(now);
render();
}
if (shuttingDown && now - shutdownStart >= SHUTDOWN_DURATION) {
powerOff();
}
if (KEEPALIVE_ENABLE && !DEBUG_CRASH_WITHOUT_PEDAL && now - lastPedalPress > KEEPALIVE_TIMEOUT && now - lastButtonPress > KEEPALIVE_TIMEOUT) {
beginShutdown(now);
}
}