#include #include #include #include #include static const uint8_t GAUGE_COUNT = 4; // Backlight/status LEDs use an addressable strip. Indicator LEDs are // single-colour active-high outputs on per-gauge pins. The command protocol // still exposes one logical LED segment per gauge. static const uint8_t LED_DATA_PIN = 22; static const uint8_t BREATHE_FRAME_MS = 16; static const uint8_t LED_SHOW_MIN_INTERVAL_MS = 16; static const uint8_t LED_SHOW_MOTION_INTERVAL_MS = 50; static const uint8_t RX_LINE_MAX = 120; static const uint16_t STEPPER_TIMER_HZ = 20000; static const uint8_t STEPPER_TIMER_PRESCALER = 8; static const uint32_t STEPPER_RATE_SCALE = 65536UL; static const uint32_t LED_SHOW_FULL_RATE_LIMIT_Q16 = (500UL * STEPPER_RATE_SCALE) / STEPPER_TIMER_HZ; static const uint32_t LED_SHOW_PAUSE_RATE_Q16 = (1500UL * STEPPER_RATE_SCALE) / STEPPER_TIMER_HZ; // For now, command and debug traffic share the same serial port. #define CMD_PORT Serial1 #define DEBUG_PORT Serial1 static const unsigned long SERIAL_BAUD = 38400; struct GaugePins { uint8_t dirPin; uint8_t stepPin; int8_t enablePin; // -1 means there is no enable pin bool dirInverted; bool stepActiveHigh; bool enableActiveLow; const char* ledOrder; // one char per LED: 'G' = GRB, 'R' = RGB; length defines ledCount int8_t indicatorRedPin; // logical LED index 3; -1 means not fitted int8_t indicatorGreenPin; // logical LED index 4; -1 means not fitted }; constexpr GaugePins gaugePins[GAUGE_COUNT] = { // dir, step, en, dirInv, stepHigh, enActiveLow, ledOrder, indRed, indGreen {48, 49, -1, false, true, true, "RRRGGRR", 2, 3}, // Gauge 0 {8, 9, -1, true, true, true, "GGGRRRR", 35, 36}, // Gauge 1 {52, 53, -1, false, true, true, "GGGRRRR", 37, 38}, // Gauge 2 {50, 51, -1, false, true, true, "GGGRRRR", 39, 40}, // Gauge 3 }; constexpr uint8_t cstrLen(const char* s) { return *s ? uint8_t(1 + cstrLen(s + 1)) : uint8_t(0); } constexpr uint8_t sumLedCounts(uint8_t i = 0) { return i >= GAUGE_COUNT ? 0 : cstrLen(gaugePins[i].ledOrder) + sumLedCounts(i + 1); } static const uint8_t TOTAL_LEDS = sumLedCounts(); constexpr bool isIndicatorLedIndex(uint8_t localIdx) { return localIdx == 3 || localIdx == 4; } constexpr uint8_t countIndicatorLedsForGauge(uint8_t gaugeIdx) { return (cstrLen(gaugePins[gaugeIdx].ledOrder) > 3 ? 1 : 0) + (cstrLen(gaugePins[gaugeIdx].ledOrder) > 4 ? 1 : 0); } constexpr uint8_t sumIndicatorLedCounts(uint8_t i = 0) { return i >= GAUGE_COUNT ? 0 : countIndicatorLedsForGauge(i) + sumIndicatorLedCounts(i + 1); } static const uint8_t TOTAL_INDICATOR_LEDS = sumIndicatorLedCounts(); static const uint8_t TOTAL_MAIN_LEDS = TOTAL_LEDS - TOTAL_INDICATOR_LEDS; enum HomingState : uint8_t { HS_IDLE, HS_START, HS_BACKING, HS_SETTLE, HS_DONE }; struct Gauge { long currentPos = 0; long targetPos = 0; long minPos = 0; long maxPos = 3780; long homingBackoffSteps = 3800; // Deliberately a touch past full reverse travel. float velocity = 0.0f; uint32_t maxSpeed = 4000; uint32_t accel = 6000; uint32_t homingSpeed = 500; unsigned long lastUpdateMicros = 0; bool enabled = true; bool homed = false; HomingState homingState = HS_IDLE; long homingStepsRemaining = 0; long homingStartPos = 0; unsigned long homingStateStartMs = 0; bool sweepEnabled = false; bool sweepTowardMax = true; }; struct StepperRuntime { volatile uint8_t* stepPort = nullptr; volatile uint8_t* dirPort = nullptr; uint8_t stepMask = 0; uint8_t dirMask = 0; bool stepActiveHigh = true; bool dirInverted = false; volatile long currentPos = 0; volatile long targetPos = 0; volatile long minPos = 0; volatile long maxPos = 3780; volatile uint32_t rateQ16 = 0; volatile uint32_t phaseQ16 = 0; volatile int8_t dir = 0; volatile bool enabled = true; volatile bool allowPastMin = false; volatile bool limitToTarget = true; volatile bool pulseActive = false; volatile bool dirKnown = false; volatile bool dirForward = true; }; enum LedFx : uint8_t { FX_BLINK = 0, FX_BREATHE = 1, FX_DFLASH = 2 }; struct BlinkState { bool active = false; LedFx fx = FX_BLINK; CRGB onColor; unsigned long lastMs = 0; uint16_t onMs = 500; uint16_t offMs = 500; bool currentlyOn = false; uint16_t periodMs = 2000; uint16_t cyclePos = 0; uint8_t dphase = 0; }; Gauge gauges[GAUGE_COUNT]; StepperRuntime steppers[GAUGE_COUNT]; char rxLine[RX_LINE_MAX + 1]; uint8_t rxLen = 0; bool rxOverflowed = false; CRGB logicalLeds[TOTAL_LEDS]; CRGB mainLeds[TOTAL_MAIN_LEDS]; CLEDController* mainLedController = nullptr; uint8_t gaugeLedOffset[GAUGE_COUNT]; uint8_t gaugeLedCount[GAUGE_COUNT]; uint8_t gaugeMainLedOffset[GAUGE_COUNT]; uint8_t ledPhysicalIdx[TOTAL_LEDS]; uint8_t ledGaugeIdx[TOTAL_LEDS]; uint8_t ledLocalIdx[TOTAL_LEDS]; bool ledIsIndicator[TOTAL_LEDS]; bool ledRgSwap[TOTAL_LEDS]; BlinkState blinkState[TOTAL_LEDS]; bool mainLedsDirty = false; unsigned long lastLedShowMs = 0; // FastLED drives the shared strip as RGB. Each gauge's ledOrder string marks per-LED // type ('R' = RGB, 'G' = GRB); writes to GRB-ordered LEDs pre-swap R and G to compensate. inline bool ledNeedsRgSwap(uint8_t globalIdx) { return ledRgSwap[globalIdx]; } inline CRGB encodeForStrip(uint8_t globalIdx, CRGB color) { if (ledNeedsRgSwap(globalIdx)) { uint8_t tmp = color.r; color.r = color.g; color.g = tmp; } return color; } inline int8_t indicatorPinFor(uint8_t gaugeIdx, uint8_t localIdx) { if (localIdx == 3) return gaugePins[gaugeIdx].indicatorRedPin; if (localIdx == 4) return gaugePins[gaugeIdx].indicatorGreenPin; return -1; } inline bool indicatorIsOn(uint8_t localIdx, CRGB color) { if (localIdx == 3) return color.r >= 128; if (localIdx == 4) return color.g >= 128; return false; } inline void writeIndicatorLed(uint8_t globalIdx, CRGB color) { uint8_t gaugeIdx = ledGaugeIdx[globalIdx]; uint8_t localIdx = ledLocalIdx[globalIdx]; int8_t pin = indicatorPinFor(gaugeIdx, localIdx); if (pin >= 0) { digitalWrite(pin, indicatorIsOn(localIdx, color) ? HIGH : LOW); } } inline void writeLed(uint8_t globalIdx, CRGB color) { logicalLeds[globalIdx] = color; if (ledIsIndicator[globalIdx]) { writeIndicatorLed(globalIdx, color); } else { mainLeds[ledPhysicalIdx[globalIdx]] = encodeForStrip(globalIdx, color); mainLedsDirty = true; } } inline CRGB readLed(uint8_t globalIdx) { return logicalLeds[globalIdx]; } uint32_t maxStepperRateQ16() { uint32_t maxRate = 0; uint8_t oldSreg = SREG; cli(); for (uint8_t i = 0; i < GAUGE_COUNT; i++) { uint32_t rate = steppers[i].rateQ16; if (steppers[i].enabled && steppers[i].dir != 0 && rate > maxRate) { maxRate = rate; } } SREG = oldSreg; return maxRate; } void showDirtyLeds() { if (!mainLedsDirty) return; uint32_t maxStepRate = maxStepperRateQ16(); if (maxStepRate >= LED_SHOW_PAUSE_RATE_Q16) return; unsigned long nowMs = millis(); uint8_t intervalMs = (maxStepRate > LED_SHOW_FULL_RATE_LIMIT_Q16) ? LED_SHOW_MOTION_INTERVAL_MS : LED_SHOW_MIN_INTERVAL_MS; if (nowMs - lastLedShowMs < intervalMs) return; if (mainLedsDirty && mainLedController != nullptr) { mainLedController->showLeds(255); mainLedsDirty = false; } else { return; } lastLedShowMs = nowMs; } // Sends one-line command replies back over the control port. // // Serial protocol summary. // // Host -> controller commands (newline-terminated ASCII): // SET // SPEED // ACCEL // ENABLE <0|1> // ZERO // HOME // HOMEALL // SWEEP // POS? // LED? // LED // BLINK [ ] // BREATHE // DFLASH // PING // // Controller -> host replies / events: // READY // Sent once from setup() after boot completes. // OK // Sent after a valid mutating command, and after POS?/LED? once all data lines // for that query have been emitted. // PONG // Sent in response to PING. // ERR BAD_CMD // Sent when a complete line matches no parser. // ERR TOO_LONG // Sent when an input line exceeds the receive buffer limit. // ERR BAD_ID // Sent by commands that take a gauge id when the id is outside 0..GAUGE_COUNT-1. // ERR BAD_SPEED // Sent by SPEED when the requested speed is <= 0. // ERR BAD_ACCEL // Sent by ACCEL when the requested acceleration is <= 0. // ERR BAD_IDX // Sent by LED/BLINK/BREATHE/DFLASH when an LED index or range is invalid. // ERR BAD_TIME // Sent by BLINK/BREATHE when the timing parameter is invalid. // POS // Emitted once per gauge before the trailing OK reply to POS?. // LED // Emitted once per configured LED before the trailing OK reply to LED?. // HOMED // Debug event printed on DEBUG_PORT when a homing sequence settles successfully. void sendReply(const char* s) { CMD_PORT.println(s); } void sendReply(const __FlashStringHelper* s) { CMD_PORT.println(s); } char* trimLine(char* s) { while (*s == ' ' || *s == '\t') s++; char* end = s + strlen(s); while (end > s && (end[-1] == ' ' || end[-1] == '\t')) { *--end = '\0'; } return s; } inline void skipSpaces(const char*& s) { while (*s == ' ' || *s == '\t') s++; } bool commandArgs(const char* line, const char* command, const char*& args) { size_t len = strlen(command); if (strncmp(line, command, len) != 0) return false; if (line[len] != ' ' && line[len] != '\t') return false; args = line + len; skipSpaces(args); return true; } bool parseIntegerToken(const char*& s, long& value) { bool negative = false; bool sawDigit = false; long parsed = 0; if (*s == '+' || *s == '-') { negative = (*s == '-'); s++; } while (*s >= '0' && *s <= '9') { sawDigit = true; parsed = parsed * 10 + (*s - '0'); s++; } if (*s == '.' || *s == ',') { s++; while (*s >= '0' && *s <= '9') { s++; } } if (!sawDigit) return false; value = negative ? -parsed : parsed; skipSpaces(s); return true; } // Tiny float absolute-value helper to avoid dragging more machinery into the sketch. float absf(float x) { return (x < 0.0f) ? -x : x; } static inline void writeFastPin(volatile uint8_t* port, uint8_t mask, bool high) { if (high) { *port |= mask; } else { *port &= (uint8_t)~mask; } } static inline void writeStepperOutput(uint8_t id, bool active) { StepperRuntime& s = steppers[id]; writeFastPin(s.stepPort, s.stepMask, active == s.stepActiveHigh); } static inline void writeStepperDir(uint8_t id, bool forward) { StepperRuntime& s = steppers[id]; writeFastPin(s.dirPort, s.dirMask, forward != s.dirInverted); s.dirKnown = true; s.dirForward = forward; } uint32_t velocityToRateQ16(float velocity) { float speed = absf(velocity); if (speed < 0.5f) return 0; return (uint32_t)(speed * ((float)STEPPER_RATE_SCALE / (float)STEPPER_TIMER_HZ)); } void initStepperRuntime(uint8_t id) { StepperRuntime& s = steppers[id]; s.stepPort = portOutputRegister(digitalPinToPort(gaugePins[id].stepPin)); s.dirPort = portOutputRegister(digitalPinToPort(gaugePins[id].dirPin)); s.stepMask = digitalPinToBitMask(gaugePins[id].stepPin); s.dirMask = digitalPinToBitMask(gaugePins[id].dirPin); s.stepActiveHigh = gaugePins[id].stepActiveHigh; s.dirInverted = gaugePins[id].dirInverted; s.currentPos = gauges[id].currentPos; s.targetPos = gauges[id].targetPos; s.minPos = gauges[id].minPos; s.maxPos = gauges[id].maxPos; s.enabled = gauges[id].enabled; writeStepperOutput(id, false); } long readStepperCurrent(uint8_t id) { uint8_t oldSreg = SREG; cli(); long pos = steppers[id].currentPos; SREG = oldSreg; return pos; } void setStepperCurrent(uint8_t id, long pos) { uint8_t oldSreg = SREG; cli(); steppers[id].currentPos = pos; steppers[id].phaseQ16 = 0; SREG = oldSreg; } void setStepperTarget(uint8_t id, long target) { uint8_t oldSreg = SREG; cli(); steppers[id].targetPos = target; SREG = oldSreg; } void setStepperEnabled(uint8_t id, bool enabled) { uint8_t oldSreg = SREG; cli(); StepperRuntime& s = steppers[id]; s.enabled = enabled; if (!enabled) { s.rateQ16 = 0; s.phaseQ16 = 0; } SREG = oldSreg; } void setStepperLimits(uint8_t id, long minPos, long maxPos) { uint8_t oldSreg = SREG; cli(); steppers[id].minPos = minPos; steppers[id].maxPos = maxPos; SREG = oldSreg; } void setStepperCommand(uint8_t id, float velocity, bool allowPastMin, bool limitToTarget) { int8_t dir = (velocity > 0.5f) ? 1 : (velocity < -0.5f ? -1 : 0); uint32_t rateQ16 = dir == 0 ? 0 : velocityToRateQ16(velocity); uint8_t oldSreg = SREG; cli(); StepperRuntime& s = steppers[id]; if (s.dir != dir) s.phaseQ16 = 0; s.dir = dir; s.rateQ16 = rateQ16; s.allowPastMin = allowPastMin; s.limitToTarget = limitToTarget; if (rateQ16 == 0) s.phaseQ16 = 0; SREG = oldSreg; } void setupStepperTimer() { uint8_t oldSreg = SREG; cli(); TCCR1A = 0; TCCR1B = 0; TCNT1 = 0; OCR1A = (uint16_t)((F_CPU / STEPPER_TIMER_PRESCALER / STEPPER_TIMER_HZ) - 1); TCCR1B |= _BV(WGM12); TCCR1B |= _BV(CS11); // prescaler 8 on ATmega2560 TIMSK1 |= _BV(OCIE1A); SREG = oldSreg; } ISR(TIMER1_COMPA_vect) { for (uint8_t i = 0; i < GAUGE_COUNT; i++) { StepperRuntime& s = steppers[i]; bool pulseJustEnded = false; if (s.pulseActive) { writeStepperOutput(i, false); s.pulseActive = false; pulseJustEnded = true; } if (!s.enabled || s.rateQ16 == 0 || s.dir == 0) continue; bool forward = s.dir > 0; if (!s.dirKnown || s.dirForward != forward) { writeStepperDir(i, forward); continue; } s.phaseQ16 += s.rateQ16; if (s.phaseQ16 < STEPPER_RATE_SCALE) continue; if (pulseJustEnded) continue; s.phaseQ16 -= STEPPER_RATE_SCALE; if (forward) { if (s.currentPos >= s.maxPos || (s.limitToTarget && s.currentPos >= s.targetPos)) { s.rateQ16 = 0; s.phaseQ16 = 0; continue; } writeStepperOutput(i, true); s.currentPos++; } else { if ((!s.allowPastMin && s.currentPos <= s.minPos) || (s.limitToTarget && s.currentPos <= s.targetPos)) { s.rateQ16 = 0; s.phaseQ16 = 0; continue; } writeStepperOutput(i, true); s.currentPos--; } s.pulseActive = true; } } // Updates the cached enable state and toggles the hardware pin if one exists. void setEnable(uint8_t id, bool en) { if (id >= GAUGE_COUNT) return; gauges[id].enabled = en; setStepperEnabled(id, en); int8_t pin = gaugePins[id].enablePin; if (pin < 0) return; bool level = gaugePins[id].enableActiveLow ? !en : en; digitalWrite(pin, level ? HIGH : LOW); } // Arms the homing state machine for one gauge and clears any in-flight motion. void requestHome(uint8_t id) { if (id >= GAUGE_COUNT) return; Gauge& g = gauges[id]; setStepperCommand(id, 0.0f, false, true); g.homingState = HS_START; g.homed = false; g.velocity = 0.0f; g.sweepEnabled = false; } // Starts the same homing sequence on every configured gauge. void requestHomeAll() { for (uint8_t i = 0; i < GAUGE_COUNT; i++) { requestHome(i); } } // Advances the simple homing state machine until the gauge is parked at logical zero. void updateHoming(uint8_t id, unsigned long nowUs, unsigned long nowMs) { Gauge& g = gauges[id]; switch (g.homingState) { case HS_IDLE: return; case HS_START: // No endstop here; homing just walks back far enough to hit the hard stop. g.velocity = 0.0f; g.homingStepsRemaining = g.homingBackoffSteps; g.homingStartPos = readStepperCurrent(id); setStepperCommand(id, -(float)g.homingSpeed, true, false); g.homingState = HS_BACKING; break; case HS_BACKING: { g.currentPos = readStepperCurrent(id); long stepsDone = g.homingStartPos - g.currentPos; g.homingStepsRemaining = g.homingBackoffSteps - stepsDone; if (stepsDone >= g.homingBackoffSteps) { setStepperCommand(id, 0.0f, false, true); g.homingState = HS_SETTLE; g.homingStateStartMs = nowMs; } break; } case HS_SETTLE: if (nowMs - g.homingStateStartMs >= 100) { g.currentPos = 0; g.targetPos = 0; g.velocity = 0.0f; setStepperCurrent(id, 0); setStepperTarget(id, 0); g.homed = true; g.homingState = HS_DONE; DEBUG_PORT.print(F("HOMED ")); DEBUG_PORT.println(id); } break; case HS_DONE: g.homingState = HS_IDLE; break; } } // Flips the sweep destination when the gauge has settled at either end of travel. void updateSweepTarget(uint8_t id) { Gauge& g = gauges[id]; if (!g.sweepEnabled || !g.homed || g.homingState != HS_IDLE) return; if (g.sweepTowardMax) { g.targetPos = g.maxPos; if (g.currentPos >= g.maxPos && absf(g.velocity) < 1.0f) { g.sweepTowardMax = false; g.targetPos = g.minPos; } } else { g.targetPos = g.minPos; if (g.currentPos <= g.minPos && absf(g.velocity) < 1.0f) { g.sweepTowardMax = true; g.targetPos = g.maxPos; } } } // Runs one gauge worth of motion control, including homing and optional sweeping. void updateGauge(uint8_t id, unsigned long nowUs, unsigned long nowMs) { Gauge& g = gauges[id]; if (g.homingState != HS_IDLE) { updateHoming(id, nowUs, nowMs); return; } g.currentPos = readStepperCurrent(id); if (!g.homed) { setStepperCommand(id, 0.0f, false, true); return; } if (g.sweepEnabled) { updateSweepTarget(id); } setStepperTarget(id, g.targetPos); if (g.lastUpdateMicros == 0) { g.lastUpdateMicros = nowUs; return; } float dt = (nowUs - g.lastUpdateMicros) / 1000000.0f; g.lastUpdateMicros = nowUs; if (dt <= 0.0f || dt > 0.1f) return; long error = g.targetPos - g.currentPos; if (error == 0 && absf(g.velocity) < 0.01f) { g.velocity = 0.0f; setStepperCommand(id, 0.0f, false, true); return; } float dir = (error > 0) ? 1.0f : (error < 0 ? -1.0f : 0.0f); // Basic trapezoidal profile: brake if the remaining travel is shorter than the stop distance. float accel = (float)g.accel; float maxSpeed = (float)g.maxSpeed; float brakingDistance = (g.velocity * g.velocity) / (2.0f * accel + 0.0001f); if ((float)labs(error) <= brakingDistance) { if (g.velocity > 0.0f) { g.velocity -= accel * dt; if (g.velocity < 0.0f) g.velocity = 0.0f; } else if (g.velocity < 0.0f) { g.velocity += accel * dt; if (g.velocity > 0.0f) g.velocity = 0.0f; } } else { g.velocity += dir * accel * dt; if (g.velocity > maxSpeed) g.velocity = maxSpeed; if (g.velocity < -maxSpeed) g.velocity = -maxSpeed; } if (absf(g.velocity) < 0.01f && error != 0) { g.velocity = dir * 5.0f; } setStepperCommand(id, g.velocity, false, true); } // Parses `SET ` and updates the target position. // Replies: `OK`, `ERR BAD_ID`. bool parseSet(const char* line) { const char* args = nullptr; long id; long pos; if (!commandArgs(line, "SET", args)) return false; if (!parseIntegerToken(args, id) || !parseIntegerToken(args, pos)) return false; if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } Gauge& g = gauges[(uint8_t)id]; pos = constrain(pos, g.minPos, g.maxPos); g.targetPos = pos; setStepperTarget((uint8_t)id, pos); g.sweepEnabled = false; sendReply(F("OK")); return true; } // Parses `SPEED ` and updates the max step rate. // Replies: `OK`, `ERR BAD_ID`, `ERR BAD_SPEED`. bool parseSpeed(const char* line) { const char* args = nullptr; long id; long speed; if (!commandArgs(line, "SPEED", args)) return false; if (!parseIntegerToken(args, id) || !parseIntegerToken(args, speed)) return false; if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } if (speed <= 0) { sendReply(F("ERR BAD_SPEED")); return true; } gauges[(uint8_t)id].maxSpeed = (uint32_t)speed; sendReply(F("OK")); return true; } // Parses `ACCEL ` and updates the acceleration limit. // Replies: `OK`, `ERR BAD_ID`, `ERR BAD_ACCEL`. bool parseAccel(const char* line) { const char* args = nullptr; long id; long accel; if (!commandArgs(line, "ACCEL", args)) return false; if (!parseIntegerToken(args, id) || !parseIntegerToken(args, accel)) return false; if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } if (accel <= 0) { sendReply(F("ERR BAD_ACCEL")); return true; } gauges[(uint8_t)id].accel = (uint32_t)accel; sendReply(F("OK")); return true; } // Parses `ENABLE <0|1>` and toggles the selected driver. // Replies: `OK`, `ERR BAD_ID`. bool parseEnable(const char* line) { int id, en; if (sscanf(line, "ENABLE %d %d", &id, &en) == 2) { if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } setEnable(id, en != 0); sendReply(F("OK")); return true; } return false; } // Parses `ZERO ` and declares the current position to be home. // Replies: `OK`, `ERR BAD_ID`. bool parseZero(const char* line) { int id; if (sscanf(line, "ZERO %d", &id) == 1) { if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } Gauge& g = gauges[id]; g.currentPos = 0; g.targetPos = 0; g.velocity = 0.0f; setStepperCommand((uint8_t)id, 0.0f, false, true); setStepperCurrent((uint8_t)id, 0); setStepperTarget((uint8_t)id, 0); g.homed = true; g.sweepEnabled = false; sendReply(F("OK")); return true; } return false; } // Parses `HOME ` or `HOMEALL` and kicks off the homing sequence. // Replies: `OK`, `ERR BAD_ID`. Successful completion later emits debug line `HOMED `. bool parseHome(const char* line) { int id; if (sscanf(line, "HOME %d", &id) == 1) { if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } requestHome(id); sendReply(F("OK")); return true; } if (strcmp(line, "HOMEALL") == 0) { requestHomeAll(); sendReply(F("OK")); return true; } return false; } // Parses `SWEEP ` and enables or disables end-to-end motion. // Replies: `OK`, `ERR BAD_ID`. bool parseSweep(const char* line) { const char* args = nullptr; long id; long accel; long speed; if (!commandArgs(line, "SWEEP", args)) return false; if (!parseIntegerToken(args, id) || !parseIntegerToken(args, accel) || !parseIntegerToken(args, speed)) { return false; } if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } Gauge& g = gauges[(uint8_t)id]; if (accel <= 0 || speed <= 0) { g.sweepEnabled = false; g.velocity = 0.0f; setStepperCommand((uint8_t)id, 0.0f, false, true); sendReply(F("OK")); return true; } g.accel = (uint32_t)accel; g.maxSpeed = (uint32_t)speed; g.sweepEnabled = true; g.sweepTowardMax = true; g.targetPos = g.maxPos; setStepperTarget((uint8_t)id, g.targetPos); sendReply(F("OK")); return true; } // Answers `POS?` with current motion state for every gauge. // Emits one `POS ` line per gauge, // then replies `OK`. bool parsePosQuery(const char* line) { if (strcmp(line, "POS?") == 0) { for (uint8_t i = 0; i < GAUGE_COUNT; i++) { gauges[i].currentPos = readStepperCurrent(i); CMD_PORT.print(F("POS ")); CMD_PORT.print(i); CMD_PORT.print(' '); CMD_PORT.print(gauges[i].currentPos); CMD_PORT.print(' '); CMD_PORT.print(gauges[i].targetPos); CMD_PORT.print(' '); CMD_PORT.print(gauges[i].homed ? 1 : 0); CMD_PORT.print(' '); CMD_PORT.print((int)gauges[i].homingState); CMD_PORT.print(' '); CMD_PORT.println(gauges[i].sweepEnabled ? 1 : 0); } sendReply(F("OK")); return true; } return false; } // Answers `CFG?` with speed and acceleration for every gauge. // Emits one `CFG ` line per gauge, then replies `OK`. bool parseCfgQuery(const char* line) { if (strcmp(line, "CFG?") == 0) { for (uint8_t i = 0; i < GAUGE_COUNT; i++) { CMD_PORT.print(F("CFG ")); CMD_PORT.print(i); CMD_PORT.print(' '); CMD_PORT.print(gauges[i].maxSpeed); CMD_PORT.print(' '); CMD_PORT.println(gauges[i].accel); } sendReply(F("OK")); return true; } return false; } // Answers the mandatory life question: are you there? // Reply: `PONG`. bool parsePing(const char* line) { if (strcmp(line, "PING") == 0) { sendReply(F("PONG")); return true; } return false; } // Answers `LED?` with the current RGB values for every configured LED. // Emits one `LED ` line per configured LED, then replies `OK`. bool parseLedQuery(const char* line) { if (strcmp(line, "LED?") == 0) { for (uint8_t i = 0; i < GAUGE_COUNT; i++) { for (uint8_t j = 0; j < gaugeLedCount[i]; j++) { CRGB c = readLed(gaugeLedOffset[i] + j); CMD_PORT.print(F("LED ")); CMD_PORT.print(i); CMD_PORT.print(' '); CMD_PORT.print(j); CMD_PORT.print(' '); CMD_PORT.print(c.r); CMD_PORT.print(' '); CMD_PORT.print(c.g); CMD_PORT.print(' '); CMD_PORT.println(c.b); } } sendReply(F("OK")); return true; } return false; } // Parses `LED ` and writes static colours. // Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`. bool parseLed(const char* line) { int id, r, g, b; char idxToken[16]; if (sscanf(line, "LED %d %15s %d %d %d", &id, idxToken, &r, &g, &b) == 5) { if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } char* dash = strchr(idxToken, '-'); int idxFirst = atoi(idxToken); int idxLast = dash ? atoi(dash + 1) : idxFirst; if (idxFirst < 0 || idxLast >= gaugeLedCount[id] || idxFirst > idxLast) { sendReply(F("ERR BAD_IDX")); return true; } CRGB color(constrain(r, 0, 255), constrain(g, 0, 255), constrain(b, 0, 255)); for (int i = idxFirst; i <= idxLast; i++) { blinkState[gaugeLedOffset[id] + i].active = false; writeLed(gaugeLedOffset[id] + i, color); } sendReply(F("OK")); return true; } return false; } // Parses `BLINK ...` and assigns a simple on/off effect to one LED or a range. // Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`, `ERR BAD_TIME`. bool parseBlink(const char* line) { int id, onMs, offMs, r, g, b; char idxToken[16]; // Optional RGB values let BLINK either reuse or replace the current colour. int count = sscanf(line, "BLINK %d %15s %d %d %d %d %d", &id, idxToken, &onMs, &offMs, &r, &g, &b); if (count != 4 && count != 7) return false; if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } char* dash = strchr(idxToken, '-'); int idxFirst = atoi(idxToken); int idxLast = dash ? atoi(dash + 1) : idxFirst; if (idxFirst < 0 || idxLast >= gaugeLedCount[id] || idxFirst > idxLast) { sendReply(F("ERR BAD_IDX")); return true; } if (onMs == 0 && offMs == 0) { for (int i = idxFirst; i <= idxLast; i++) blinkState[gaugeLedOffset[id] + i].active = false; sendReply(F("OK")); return true; } if (onMs <= 0 || offMs <= 0) { sendReply(F("ERR BAD_TIME")); return true; } CRGB color = (count == 7) ? CRGB(constrain(r, 0, 255), constrain(g, 0, 255), constrain(b, 0, 255)) : CRGB(0, 0, 0); // Placeholder; replaced with the live LED colour below. unsigned long nowMs = millis(); for (int i = idxFirst; i <= idxLast; i++) { uint8_t globalIdx = gaugeLedOffset[id] + i; BlinkState& bs = blinkState[globalIdx]; bs.fx = FX_BLINK; bs.onColor = (count == 7) ? color : readLed(globalIdx); bs.onMs = (uint16_t)onMs; bs.offMs = (uint16_t)offMs; bs.currentlyOn = true; bs.lastMs = nowMs; bs.active = true; writeLed(globalIdx, bs.onColor); } sendReply(F("OK")); return true; } // Parses `BREATHE ...` and assigns a triangle-wave fade effect. // Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`, `ERR BAD_TIME`. bool parseBreathe(const char* line) { int id, periodMs, r, g, b; char idxToken[16]; if (sscanf(line, "BREATHE %d %15s %d %d %d %d", &id, idxToken, &periodMs, &r, &g, &b) != 6) return false; if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } char* dash = strchr(idxToken, '-'); int idxFirst = atoi(idxToken); int idxLast = dash ? atoi(dash + 1) : idxFirst; if (idxFirst < 0 || idxLast >= gaugeLedCount[id] || idxFirst > idxLast) { sendReply(F("ERR BAD_IDX")); return true; } if (periodMs <= 0) { sendReply(F("ERR BAD_TIME")); return true; } CRGB color(constrain(r, 0, 255), constrain(g, 0, 255), constrain(b, 0, 255)); unsigned long nowMs = millis(); for (int i = idxFirst; i <= idxLast; i++) { uint8_t gi = gaugeLedOffset[id] + i; BlinkState& bs = blinkState[gi]; bs.fx = FX_BREATHE; bs.onColor = color; bs.periodMs = (uint16_t)constrain(periodMs, 100, 30000); bs.cyclePos = 0; bs.lastMs = nowMs; bs.active = true; writeLed(gi, CRGB::Black); } sendReply(F("OK")); return true; } // Parses `DFLASH ...` and assigns the double-flash pattern. // Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`. bool parseDflash(const char* line) { int id, r, g, b; char idxToken[16]; if (sscanf(line, "DFLASH %d %15s %d %d %d", &id, idxToken, &r, &g, &b) != 5) return false; if (id < 0 || id >= GAUGE_COUNT) { sendReply(F("ERR BAD_ID")); return true; } char* dash = strchr(idxToken, '-'); int idxFirst = atoi(idxToken); int idxLast = dash ? atoi(dash + 1) : idxFirst; if (idxFirst < 0 || idxLast >= gaugeLedCount[id] || idxFirst > idxLast) { sendReply(F("ERR BAD_IDX")); return true; } CRGB color(constrain(r, 0, 255), constrain(g, 0, 255), constrain(b, 0, 255)); unsigned long nowMs = millis(); for (int i = idxFirst; i <= idxLast; i++) { uint8_t gi = gaugeLedOffset[id] + i; BlinkState& bs = blinkState[gi]; bs.fx = FX_DFLASH; bs.onColor = color; bs.dphase = 0; bs.lastMs = nowMs; bs.active = true; writeLed(gi, color); // phase 0 = on } sendReply(F("OK")); return true; } // Advances all active LED effects. writeLed() marks the affected physical strip dirty. void updateBlink() { unsigned long nowMs = millis(); for (uint8_t i = 0; i < GAUGE_COUNT; i++) { for (uint8_t j = 0; j < gaugeLedCount[i]; j++) { uint8_t gi = gaugeLedOffset[i] + j; BlinkState& bs = blinkState[gi]; if (!bs.active) continue; switch (bs.fx) { case FX_BLINK: { uint32_t period = bs.currentlyOn ? bs.onMs : bs.offMs; if ((nowMs - bs.lastMs) >= period) { bs.currentlyOn = !bs.currentlyOn; bs.lastMs = nowMs; writeLed(gi, bs.currentlyOn ? bs.onColor : CRGB::Black); } break; } case FX_BREATHE: { unsigned long dt = nowMs - bs.lastMs; if (dt < BREATHE_FRAME_MS) break; uint32_t newPos = (uint32_t)bs.cyclePos + dt; bs.cyclePos = (uint16_t)(newPos % bs.periodMs); bs.lastMs = nowMs; // Triangle wave brightness; frame-limited so breathe remains smooth // without refreshing the LED strips on every service-loop pass. uint16_t half = bs.periodMs >> 1; uint8_t bri = (bs.cyclePos < half) ? (uint8_t)((uint32_t)bs.cyclePos * 255 / half) : (uint8_t)((uint32_t)(bs.periodMs - bs.cyclePos) * 255 / half); CRGB scaled = bs.onColor; scaled.nscale8(bri ? bri : 1); writeLed(gi, scaled); break; } case FX_DFLASH: { static const uint16_t dur[4] = {100, 100, 100, 700}; // on, off, on, longer off if ((nowMs - bs.lastMs) >= dur[bs.dphase]) { bs.lastMs = nowMs; bs.dphase = (bs.dphase + 1) & 3; writeLed(gi, (bs.dphase == 0 || bs.dphase == 2) ? bs.onColor : CRGB::Black); } break; } } } } } // Runs the command parsers in order until one claims the line. // Reply: `ERR BAD_CMD` when no parser accepts the line. void processLine(const char* line) { if (parseSet(line)) return; if (parseSpeed(line)) return; if (parseAccel(line)) return; if (parseEnable(line)) return; if (parseZero(line)) return; if (parseHome(line)) return; if (parseSweep(line)) return; if (parsePosQuery(line)) return; if (parseCfgQuery(line)) return; if (parseLedQuery(line)) return; if (parseLed(line)) return; if (parseBlink(line)) return; if (parseBreathe(line)) return; if (parseDflash(line)) return; if (parsePing(line)) return; sendReply(F("ERR BAD_CMD")); } // Reads newline-delimited commands from serial and hands complete lines to the parser. // Reply: `ERR TOO_LONG` when the buffered line exceeds the receive limit before newline. void readCommands() { while (CMD_PORT.available()) { char c = (char)CMD_PORT.read(); if (c == '\n') { if (rxOverflowed) { rxOverflowed = false; rxLen = 0; continue; } rxLine[rxLen] = '\0'; char* line = trimLine(rxLine); if (*line != '\0') { processLine(line); } rxLen = 0; } else if (c != '\r' && !rxOverflowed) { if (rxLen < RX_LINE_MAX) { rxLine[rxLen++] = c; } else { rxLen = 0; rxOverflowed = true; sendReply(F("ERR TOO_LONG")); } } } } // Initialises pins, LED bookkeeping and the initial homing cycle. // Reply/event: emits `READY` on CMD_PORT once boot is complete. void setup() { DEBUG_PORT.begin(SERIAL_BAUD); DEBUG_PORT.println(F("Gauge controller booting")); for (uint8_t i = 0; i < GAUGE_COUNT; i++) { pinMode(gaugePins[i].dirPin, OUTPUT); pinMode(gaugePins[i].stepPin, OUTPUT); digitalWrite(gaugePins[i].dirPin, LOW); digitalWrite(gaugePins[i].stepPin, gaugePins[i].stepActiveHigh ? LOW : HIGH); if (gaugePins[i].enablePin >= 0) { pinMode(gaugePins[i].enablePin, OUTPUT); setEnable(i, true); } if (gaugePins[i].indicatorRedPin >= 0) { pinMode(gaugePins[i].indicatorRedPin, OUTPUT); digitalWrite(gaugePins[i].indicatorRedPin, LOW); } if (gaugePins[i].indicatorGreenPin >= 0) { pinMode(gaugePins[i].indicatorGreenPin, OUTPUT); digitalWrite(gaugePins[i].indicatorGreenPin, LOW); } initStepperRuntime(i); setStepperLimits(i, gauges[i].minPos, gauges[i].maxPos); gauges[i].lastUpdateMicros = micros(); } // Flatten the per-gauge LED counts into logical offsets and physical // offsets for the addressable main strip. uint8_t ledOff = 0; uint8_t mainLedOff = 0; for (uint8_t i = 0; i < GAUGE_COUNT; i++) { gaugeLedCount[i] = cstrLen(gaugePins[i].ledOrder); gaugeLedOffset[i] = ledOff; gaugeMainLedOffset[i] = mainLedOff; for (uint8_t localIdx = 0; localIdx < gaugeLedCount[i]; localIdx++) { uint8_t globalIdx = ledOff + localIdx; bool indicator = isIndicatorLedIndex(localIdx); ledGaugeIdx[globalIdx] = i; ledLocalIdx[globalIdx] = localIdx; ledIsIndicator[globalIdx] = indicator; ledRgSwap[globalIdx] = gaugePins[i].ledOrder[localIdx] == 'G' || gaugePins[i].ledOrder[localIdx] == 'g'; ledPhysicalIdx[globalIdx] = indicator ? 0 : mainLedOff + localIdx - (localIdx > 4 ? 2 : 0); } ledOff += gaugeLedCount[i]; mainLedOff += gaugeLedCount[i] - countIndicatorLedsForGauge(i); } mainLedController = &FastLED.addLeds(mainLeds, TOTAL_MAIN_LEDS); FastLED.setBrightness(255); mainLedController->showLeds(255); setupStepperTimer(); requestHomeAll(); DEBUG_PORT.println(F("READY")); // Boot-complete handshake for the command channel. sendReply(F("READY")); } // Main service loop: ingest commands, advance effects, move gauges, flush LEDs. void loop() { readCommands(); updateBlink(); unsigned long nowUs = micros(); unsigned long nowMs = millis(); for (uint8_t i = 0; i < GAUGE_COUNT; i++) { updateGauge(i, nowUs, nowMs); } showDirtyLeds(); }