diff --git a/Gaugecontroller/Gaugecontroller.ino b/Gaugecontroller/Gaugecontroller.ino index 45bb28f..2754a8a 100644 --- a/Gaugecontroller/Gaugecontroller.ino +++ b/Gaugecontroller/Gaugecontroller.ino @@ -1,228 +1,13 @@ #include -#include #include -#include static const uint8_t GAUGE_COUNT = 4; -// Backlight/status LEDs and indicator LEDs use separate data strips because -// their LED chipsets are not compatible on one chain. The command protocol -// still exposes one logical LED segment per gauge. -static const uint8_t LED_DATA_PIN = 22; -static const uint8_t INDICATOR_LED_DATA_PIN = 36; -static const uint8_t BREATHE_FRAME_MS = 16; - // For now, command and debug traffic share the same serial port. -#define CMD_PORT Serial1 -#define DEBUG_PORT Serial1 +#define CMD_PORT Serial +#define DEBUG_PORT Serial static const unsigned long SERIAL_BAUD = 38400; -namespace vfd { - -constexpr uint8_t kDataPin = 46; -constexpr uint8_t kClockPin = 47; -constexpr uint8_t kLatchPin = 44; -constexpr int8_t kBlankPin = 45; // Set to -1 if BL/OE is not connected -constexpr bool kBlankActiveHigh = true; - -constexpr unsigned long kDigitHoldMicros = 2000; -constexpr uint8_t kDigitCount = 4; -constexpr uint8_t kSegmentCount = 7; -constexpr uint8_t kDriverBits = 20; - -constexpr uint8_t kSegmentStartBit = 0; // HVOut1 -> bit 0 -constexpr uint8_t kPointSegmentBit = 7; // HVOut8 -> bit 7 -constexpr uint8_t kBellSegmentBit = 8; // HVOut9 -> bit 8 -constexpr uint8_t kGridStartBit = 9; // HVOut10 -> bit 9 -constexpr uint8_t kIndicatorGridBit = 13; // HVOut14 -> bit 13 - -char displayBuffer[kDigitCount] = {' ', ' ', ' ', ' '}; -bool pointEnabled = false; -bool bellEnabled = false; -uint8_t currentPhase = 0; - -uint8_t encodeCharacter(char c) { - switch (c) { - case '0': return 0b0111111; - case '1': return 0b0000110; - case '2': return 0b1011011; - case '3': return 0b1001111; - case '4': return 0b1100110; - case '5': return 0b1101101; - case '6': return 0b1111101; - case '7': return 0b0000111; - case '8': return 0b1111111; - case '9': return 0b1101111; - case 'A': - case 'a': return 0b1110111; - case 'B': - case 'b': return 0b1111100; - case 'C': - case 'c': return 0b0111001; - case 'D': - case 'd': return 0b1011110; - case 'E': - case 'e': return 0b1111001; - case 'F': - case 'f': return 0b1110001; - case '-': return 0b1000000; - default: return 0; - } -} - -void shiftDriverWord(uint32_t word) { - digitalWrite(kLatchPin, HIGH); - digitalWrite(kClockPin, HIGH); - - for (int8_t bit = kDriverBits - 1; bit >= 0; --bit) { - digitalWrite(kDataPin, (word >> bit) & 0x1U ? HIGH : LOW); - digitalWrite(kClockPin, LOW); - digitalWrite(kClockPin, HIGH); - } - - digitalWrite(kLatchPin, LOW); - digitalWrite(kLatchPin, HIGH); -} - -void setBlanked(bool blanked) { - if (kBlankPin < 0) return; - - const bool level = kBlankActiveHigh ? blanked : !blanked; - digitalWrite(kBlankPin, level ? HIGH : LOW); -} - -void writeText(const char* text) { - for (uint8_t i = 0; i < kDigitCount; ++i) { - displayBuffer[i] = ' '; - } - - size_t len = strlen(text); - if (len > kDigitCount) { - text += len - kDigitCount; - len = kDigitCount; - } - - const uint8_t start = kDigitCount - len; - for (uint8_t i = 0; i < len; ++i) { - displayBuffer[start + i] = text[i]; - } -} - -void clear() { - writeText(""); - pointEnabled = false; - bellEnabled = false; -} - -bool parseCommand(const String& command) { - char displayText[16]; - size_t inputIndex = 0; - size_t displayIndex = 0; - - if (command.length() == 0) { - return false; - } - - if (command[inputIndex] == '-') { - if (displayIndex + 1 >= sizeof(displayText)) { - return false; - } - displayText[displayIndex++] = command[inputIndex++]; - } - - const size_t digitStart = inputIndex; - while (inputIndex < static_cast(command.length()) && - isxdigit(static_cast(command[inputIndex]))) { - if (displayIndex + 1 >= sizeof(displayText)) { - return false; - } - displayText[displayIndex++] = toupper(static_cast(command[inputIndex])); - ++inputIndex; - } - - if (inputIndex == digitStart) { - return false; - } - - bool newPointEnabled = false; - bool newBellEnabled = false; - while (inputIndex < static_cast(command.length())) { - if (command[inputIndex] == '.') { - newPointEnabled = true; - } else if (command[inputIndex] == '!') { - newBellEnabled = true; - } else { - return false; - } - ++inputIndex; - } - - displayText[displayIndex] = '\0'; - writeText(displayText); - pointEnabled = newPointEnabled; - bellEnabled = newBellEnabled; - return true; -} - -void renderDigit(uint8_t digitIndex) { - uint32_t word = 0; - const uint8_t segments = encodeCharacter(displayBuffer[digitIndex]); - - for (uint8_t segment = 0; segment < kSegmentCount; ++segment) { - if ((segments >> segment) & 0x1U) { - word |= (1UL << (kSegmentStartBit + segment)); - } - } - - word |= (1UL << (kGridStartBit + digitIndex)); - shiftDriverWord(word); -} - -void renderIndicator() { - uint32_t word = 1UL << kIndicatorGridBit; - if (pointEnabled) { - word |= 1UL << kPointSegmentBit; - } - if (bellEnabled) { - word |= 1UL << kBellSegmentBit; - } - shiftDriverWord(word); -} - -void begin() { - pinMode(kDataPin, OUTPUT); - pinMode(kClockPin, OUTPUT); - pinMode(kLatchPin, OUTPUT); - if (kBlankPin >= 0) { - pinMode(kBlankPin, OUTPUT); - } - - digitalWrite(kDataPin, LOW); - digitalWrite(kClockPin, HIGH); - digitalWrite(kLatchPin, HIGH); - setBlanked(true); - writeText("0"); - shiftDriverWord(0); -} - -void refresh() { - setBlanked(true); - if (currentPhase < kDigitCount) { - renderDigit(currentPhase); - } else if (pointEnabled || bellEnabled) { - renderIndicator(); - } else { - shiftDriverWord(0); - } - setBlanked(false); - delayMicroseconds(kDigitHoldMicros); - setBlanked(true); - - currentPhase = (currentPhase + 1) % (kDigitCount + 1); -} - -} // namespace vfd - struct GaugePins { uint8_t dirPin; uint8_t stepPin; @@ -230,41 +15,16 @@ struct GaugePins { bool dirInverted; bool stepActiveHigh; bool enableActiveLow; - const char* ledOrder; // one char per LED: 'G' = GRB, 'R' = RGB; length defines ledCount }; constexpr GaugePins gaugePins[GAUGE_COUNT] = { - // dir, step, en, dirInv, stepHigh, enActiveLow, ledOrder - {48, 49, -1, false, true, true, "RRRGGRR"}, // Gauge 0 - {8, 9, -1, true, true, true, "GGGRRRR"}, // Gauge 1 - {52, 53, -1, false, true, true, "GGGRRRR"}, // Gauge 2 - {50, 51, -1, false, true, true, "GGGRRRR"}, // Gauge 3 + // dir, step, en, dirInv, stepHigh, enActiveLow + {48, 49, -1, false, true, true}, // Gauge 0 + {8, 9, -1, true, true, true}, // Gauge 1 + {52, 53, -1, false, true, true}, // Gauge 2 + {50, 51, -1, false, true, true}, // 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, @@ -301,107 +61,9 @@ struct Gauge { bool sweepTowardMax = 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]; String rxLine; -CRGB logicalLeds[TOTAL_LEDS]; -CRGB mainLeds[TOTAL_MAIN_LEDS]; -CRGB indicatorLeds[TOTAL_INDICATOR_LEDS]; -CLEDController* mainLedController = nullptr; -CLEDController* indicatorLedController = nullptr; -uint8_t gaugeLedOffset[GAUGE_COUNT]; -uint8_t gaugeLedCount[GAUGE_COUNT]; -uint8_t gaugeMainLedOffset[GAUGE_COUNT]; -uint8_t gaugeIndicatorLedOffset[GAUGE_COUNT]; -BlinkState blinkState[TOTAL_LEDS]; -bool mainLedsDirty = false; -bool indicatorLedsDirty = false; - -// 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) { - for (uint8_t i = 0; i < GAUGE_COUNT; i++) { - uint8_t off = gaugeLedOffset[i]; - if (globalIdx >= off && globalIdx < off + gaugeLedCount[i]) { - char c = gaugePins[i].ledOrder[globalIdx - off]; - return c == 'G' || c == 'g'; - } - } - return false; -} - -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; -} - -bool ledPhysicalIndex(uint8_t globalIdx, bool& indicatorStrip, uint8_t& physicalIdx) { - for (uint8_t i = 0; i < GAUGE_COUNT; i++) { - uint8_t off = gaugeLedOffset[i]; - if (globalIdx < off || globalIdx >= off + gaugeLedCount[i]) continue; - - uint8_t localIdx = globalIdx - off; - indicatorStrip = isIndicatorLedIndex(localIdx); - if (indicatorStrip) { - physicalIdx = gaugeIndicatorLedOffset[i] + (localIdx - 3); - } else { - physicalIdx = gaugeMainLedOffset[i] + localIdx - (localIdx > 4 ? 2 : 0); - } - return true; - } - return false; -} - -inline void writeLed(uint8_t globalIdx, CRGB color) { - logicalLeds[globalIdx] = color; - - bool indicatorStrip = false; - uint8_t physicalIdx = 0; - if (!ledPhysicalIndex(globalIdx, indicatorStrip, physicalIdx)) return; - - if (indicatorStrip) { - indicatorLeds[physicalIdx] = encodeForStrip(globalIdx, color); - indicatorLedsDirty = true; - } else { - mainLeds[physicalIdx] = encodeForStrip(globalIdx, color); - mainLedsDirty = true; - } -} - -inline CRGB readLed(uint8_t globalIdx) { - return logicalLeds[globalIdx]; -} - -void showDirtyLeds() { - if (mainLedsDirty && mainLedController != nullptr) { - mainLedController->showLeds(255); - mainLedsDirty = false; - } - if (indicatorLedsDirty && indicatorLedController != nullptr) { - indicatorLedController->showLeds(255); - indicatorLedsDirty = false; - } -} - // Sends one-line command replies back over the control port. // // Serial protocol summary. @@ -416,19 +78,14 @@ void showDirtyLeds() { // HOMEALL // SWEEP // POS? -// LED? -// LED -// BLINK [ ] -// BREATHE -// DFLASH -// VFD +// CFG? // 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 +// Sent after a valid mutating command, and after POS?/CFG? once all data lines // for that query have been emitted. // PONG // Sent in response to PING. @@ -442,16 +99,10 @@ void showDirtyLeds() { // 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. -// ERR BAD_VFD -// Sent by VFD when the text payload is malformed. // 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?. +// CFG +// Emitted once per gauge before the trailing OK reply to CFG?. // HOMED // Debug event printed on DEBUG_PORT when a homing sequence settles successfully. void sendReply(const String& s) { @@ -924,241 +575,6 @@ bool parsePing(const String& line) { return false; } -// Parses `VFD ` where is up to four hex characters with optional `.` and `!` suffixes. -// Replies: `OK`, `ERR BAD_VFD`. -bool parseVfd(const String& line) { - if (line == "VFD") { - vfd::clear(); - sendReply("OK"); - return true; - } - - if (!line.startsWith("VFD ")) return false; - - const String payload = line.substring(4); - if (payload.length() == 0) { - vfd::clear(); - sendReply("OK"); - return true; - } - - if (vfd::parseCommand(payload)) { - sendReply("OK"); - } else { - sendReply("ERR BAD_VFD"); - } - return true; -} - -// Answers `LED?` with the current RGB values for every configured LED. -// Emits one `LED ` line per configured LED, then replies `OK`. -bool parseLedQuery(const String& line) { - if (line == "LED?") { - 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("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("OK"); - return true; - } - return false; -} - -// Parses `LED ` and writes static colours. -// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`. -bool parseLed(const String& line) { - int id, r, g, b; - char idxToken[16]; - if (sscanf(line.c_str(), "LED %d %15s %d %d %d", &id, idxToken, &r, &g, &b) == 5) { - if (id < 0 || id >= GAUGE_COUNT) { sendReply("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("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("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 String& 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.c_str(), "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("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("ERR BAD_IDX"); return true; - } - - if (onMs == 0 && offMs == 0) { - for (int i = idxFirst; i <= idxLast; i++) - blinkState[gaugeLedOffset[id] + i].active = false; - sendReply("OK"); - return true; - } - if (onMs <= 0 || offMs <= 0) { sendReply("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("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 String& line) { - int id, periodMs, r, g, b; - char idxToken[16]; - if (sscanf(line.c_str(), "BREATHE %d %15s %d %d %d %d", - &id, idxToken, &periodMs, &r, &g, &b) != 6) return false; - if (id < 0 || id >= GAUGE_COUNT) { sendReply("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("ERR BAD_IDX"); return true; - } - if (periodMs <= 0) { sendReply("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("OK"); - return true; -} - -// Parses `DFLASH ...` and assigns the double-flash pattern. -// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`. -bool parseDflash(const String& line) { - int id, r, g, b; - char idxToken[16]; - if (sscanf(line.c_str(), "DFLASH %d %15s %d %d %d", - &id, idxToken, &r, &g, &b) != 5) return false; - if (id < 0 || id >= GAUGE_COUNT) { sendReply("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("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("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 String& line) { @@ -1171,12 +587,6 @@ void processLine(const String& line) { 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 (parseVfd(line)) return; if (parsePing(line)) return; sendReply("ERR BAD_CMD"); @@ -1204,7 +614,7 @@ void readCommands() { } } -// Initialises pins, LED bookkeeping and the initial homing cycle. +// Initialises stepper pins and the initial homing cycle. // Reply/event: emits `READY` on CMD_PORT once boot is complete. void setup() { DEBUG_PORT.begin(SERIAL_BAUD); @@ -1225,28 +635,6 @@ void setup() { gauges[i].lastUpdateMicros = micros(); } - // Flatten the per-gauge LED counts into logical offsets and separate - // physical offsets for the main and indicator strips. - uint8_t ledOff = 0; - uint8_t mainLedOff = 0; - uint8_t indicatorLedOff = 0; - for (uint8_t i = 0; i < GAUGE_COUNT; i++) { - gaugeLedCount[i] = cstrLen(gaugePins[i].ledOrder); - gaugeLedOffset[i] = ledOff; - gaugeMainLedOffset[i] = mainLedOff; - gaugeIndicatorLedOffset[i] = indicatorLedOff; - ledOff += gaugeLedCount[i]; - indicatorLedOff += countIndicatorLedsForGauge(i); - mainLedOff += gaugeLedCount[i] - countIndicatorLedsForGauge(i); - } - mainLedController = &FastLED.addLeds(mainLeds, TOTAL_MAIN_LEDS); - indicatorLedController = &FastLED.addLeds(indicatorLeds, TOTAL_INDICATOR_LEDS); - FastLED.setBrightness(255); - mainLedController->showLeds(255); - indicatorLedController->showLeds(255); - - vfd::begin(); - requestHomeAll(); DEBUG_PORT.println("READY"); @@ -1254,17 +642,11 @@ void setup() { sendReply("READY"); } -// Main service loop: ingest commands, advance effects, move gauges, flush LEDs. +// Main service loop: ingest commands and move gauges. void loop() { readCommands(); - vfd::refresh(); - updateBlink(); for (uint8_t i = 0; i < GAUGE_COUNT; i++) { updateGauge(i); } - - showDirtyLeds(); - - } diff --git a/Gaugecontroller_WithLeds/Gaugecontroller.ino b/Gaugecontroller_WithLeds/Gaugecontroller.ino new file mode 100644 index 0000000..45bb28f --- /dev/null +++ b/Gaugecontroller_WithLeds/Gaugecontroller.ino @@ -0,0 +1,1270 @@ +#include +#include +#include +#include + +static const uint8_t GAUGE_COUNT = 4; + +// Backlight/status LEDs and indicator LEDs use separate data strips because +// their LED chipsets are not compatible on one chain. The command protocol +// still exposes one logical LED segment per gauge. +static const uint8_t LED_DATA_PIN = 22; +static const uint8_t INDICATOR_LED_DATA_PIN = 36; +static const uint8_t BREATHE_FRAME_MS = 16; + +// 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; + +namespace vfd { + +constexpr uint8_t kDataPin = 46; +constexpr uint8_t kClockPin = 47; +constexpr uint8_t kLatchPin = 44; +constexpr int8_t kBlankPin = 45; // Set to -1 if BL/OE is not connected +constexpr bool kBlankActiveHigh = true; + +constexpr unsigned long kDigitHoldMicros = 2000; +constexpr uint8_t kDigitCount = 4; +constexpr uint8_t kSegmentCount = 7; +constexpr uint8_t kDriverBits = 20; + +constexpr uint8_t kSegmentStartBit = 0; // HVOut1 -> bit 0 +constexpr uint8_t kPointSegmentBit = 7; // HVOut8 -> bit 7 +constexpr uint8_t kBellSegmentBit = 8; // HVOut9 -> bit 8 +constexpr uint8_t kGridStartBit = 9; // HVOut10 -> bit 9 +constexpr uint8_t kIndicatorGridBit = 13; // HVOut14 -> bit 13 + +char displayBuffer[kDigitCount] = {' ', ' ', ' ', ' '}; +bool pointEnabled = false; +bool bellEnabled = false; +uint8_t currentPhase = 0; + +uint8_t encodeCharacter(char c) { + switch (c) { + case '0': return 0b0111111; + case '1': return 0b0000110; + case '2': return 0b1011011; + case '3': return 0b1001111; + case '4': return 0b1100110; + case '5': return 0b1101101; + case '6': return 0b1111101; + case '7': return 0b0000111; + case '8': return 0b1111111; + case '9': return 0b1101111; + case 'A': + case 'a': return 0b1110111; + case 'B': + case 'b': return 0b1111100; + case 'C': + case 'c': return 0b0111001; + case 'D': + case 'd': return 0b1011110; + case 'E': + case 'e': return 0b1111001; + case 'F': + case 'f': return 0b1110001; + case '-': return 0b1000000; + default: return 0; + } +} + +void shiftDriverWord(uint32_t word) { + digitalWrite(kLatchPin, HIGH); + digitalWrite(kClockPin, HIGH); + + for (int8_t bit = kDriverBits - 1; bit >= 0; --bit) { + digitalWrite(kDataPin, (word >> bit) & 0x1U ? HIGH : LOW); + digitalWrite(kClockPin, LOW); + digitalWrite(kClockPin, HIGH); + } + + digitalWrite(kLatchPin, LOW); + digitalWrite(kLatchPin, HIGH); +} + +void setBlanked(bool blanked) { + if (kBlankPin < 0) return; + + const bool level = kBlankActiveHigh ? blanked : !blanked; + digitalWrite(kBlankPin, level ? HIGH : LOW); +} + +void writeText(const char* text) { + for (uint8_t i = 0; i < kDigitCount; ++i) { + displayBuffer[i] = ' '; + } + + size_t len = strlen(text); + if (len > kDigitCount) { + text += len - kDigitCount; + len = kDigitCount; + } + + const uint8_t start = kDigitCount - len; + for (uint8_t i = 0; i < len; ++i) { + displayBuffer[start + i] = text[i]; + } +} + +void clear() { + writeText(""); + pointEnabled = false; + bellEnabled = false; +} + +bool parseCommand(const String& command) { + char displayText[16]; + size_t inputIndex = 0; + size_t displayIndex = 0; + + if (command.length() == 0) { + return false; + } + + if (command[inputIndex] == '-') { + if (displayIndex + 1 >= sizeof(displayText)) { + return false; + } + displayText[displayIndex++] = command[inputIndex++]; + } + + const size_t digitStart = inputIndex; + while (inputIndex < static_cast(command.length()) && + isxdigit(static_cast(command[inputIndex]))) { + if (displayIndex + 1 >= sizeof(displayText)) { + return false; + } + displayText[displayIndex++] = toupper(static_cast(command[inputIndex])); + ++inputIndex; + } + + if (inputIndex == digitStart) { + return false; + } + + bool newPointEnabled = false; + bool newBellEnabled = false; + while (inputIndex < static_cast(command.length())) { + if (command[inputIndex] == '.') { + newPointEnabled = true; + } else if (command[inputIndex] == '!') { + newBellEnabled = true; + } else { + return false; + } + ++inputIndex; + } + + displayText[displayIndex] = '\0'; + writeText(displayText); + pointEnabled = newPointEnabled; + bellEnabled = newBellEnabled; + return true; +} + +void renderDigit(uint8_t digitIndex) { + uint32_t word = 0; + const uint8_t segments = encodeCharacter(displayBuffer[digitIndex]); + + for (uint8_t segment = 0; segment < kSegmentCount; ++segment) { + if ((segments >> segment) & 0x1U) { + word |= (1UL << (kSegmentStartBit + segment)); + } + } + + word |= (1UL << (kGridStartBit + digitIndex)); + shiftDriverWord(word); +} + +void renderIndicator() { + uint32_t word = 1UL << kIndicatorGridBit; + if (pointEnabled) { + word |= 1UL << kPointSegmentBit; + } + if (bellEnabled) { + word |= 1UL << kBellSegmentBit; + } + shiftDriverWord(word); +} + +void begin() { + pinMode(kDataPin, OUTPUT); + pinMode(kClockPin, OUTPUT); + pinMode(kLatchPin, OUTPUT); + if (kBlankPin >= 0) { + pinMode(kBlankPin, OUTPUT); + } + + digitalWrite(kDataPin, LOW); + digitalWrite(kClockPin, HIGH); + digitalWrite(kLatchPin, HIGH); + setBlanked(true); + writeText("0"); + shiftDriverWord(0); +} + +void refresh() { + setBlanked(true); + if (currentPhase < kDigitCount) { + renderDigit(currentPhase); + } else if (pointEnabled || bellEnabled) { + renderIndicator(); + } else { + shiftDriverWord(0); + } + setBlanked(false); + delayMicroseconds(kDigitHoldMicros); + setBlanked(true); + + currentPhase = (currentPhase + 1) % (kDigitCount + 1); +} + +} // namespace vfd + +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 +}; + +constexpr GaugePins gaugePins[GAUGE_COUNT] = { + // dir, step, en, dirInv, stepHigh, enActiveLow, ledOrder + {48, 49, -1, false, true, true, "RRRGGRR"}, // Gauge 0 + {8, 9, -1, true, true, true, "GGGRRRR"}, // Gauge 1 + {52, 53, -1, false, true, true, "GGGRRRR"}, // Gauge 2 + {50, 51, -1, false, true, true, "GGGRRRR"}, // 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; + float maxSpeed = 4000.0f; + float accel = 6000.0f; + float homingSpeed = 500.0f; + + float stepAccumulator = 0.0f; + unsigned long lastUpdateMicros = 0; + + bool enabled = true; + bool homed = false; + + HomingState homingState = HS_IDLE; + long homingStepsRemaining = 0; + unsigned long homingLastStepMicros = 0; + unsigned long homingStateStartMs = 0; + + bool sweepEnabled = false; + bool sweepTowardMax = 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]; +String rxLine; + +CRGB logicalLeds[TOTAL_LEDS]; +CRGB mainLeds[TOTAL_MAIN_LEDS]; +CRGB indicatorLeds[TOTAL_INDICATOR_LEDS]; +CLEDController* mainLedController = nullptr; +CLEDController* indicatorLedController = nullptr; +uint8_t gaugeLedOffset[GAUGE_COUNT]; +uint8_t gaugeLedCount[GAUGE_COUNT]; +uint8_t gaugeMainLedOffset[GAUGE_COUNT]; +uint8_t gaugeIndicatorLedOffset[GAUGE_COUNT]; +BlinkState blinkState[TOTAL_LEDS]; +bool mainLedsDirty = false; +bool indicatorLedsDirty = false; + +// 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) { + for (uint8_t i = 0; i < GAUGE_COUNT; i++) { + uint8_t off = gaugeLedOffset[i]; + if (globalIdx >= off && globalIdx < off + gaugeLedCount[i]) { + char c = gaugePins[i].ledOrder[globalIdx - off]; + return c == 'G' || c == 'g'; + } + } + return false; +} + +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; +} + +bool ledPhysicalIndex(uint8_t globalIdx, bool& indicatorStrip, uint8_t& physicalIdx) { + for (uint8_t i = 0; i < GAUGE_COUNT; i++) { + uint8_t off = gaugeLedOffset[i]; + if (globalIdx < off || globalIdx >= off + gaugeLedCount[i]) continue; + + uint8_t localIdx = globalIdx - off; + indicatorStrip = isIndicatorLedIndex(localIdx); + if (indicatorStrip) { + physicalIdx = gaugeIndicatorLedOffset[i] + (localIdx - 3); + } else { + physicalIdx = gaugeMainLedOffset[i] + localIdx - (localIdx > 4 ? 2 : 0); + } + return true; + } + return false; +} + +inline void writeLed(uint8_t globalIdx, CRGB color) { + logicalLeds[globalIdx] = color; + + bool indicatorStrip = false; + uint8_t physicalIdx = 0; + if (!ledPhysicalIndex(globalIdx, indicatorStrip, physicalIdx)) return; + + if (indicatorStrip) { + indicatorLeds[physicalIdx] = encodeForStrip(globalIdx, color); + indicatorLedsDirty = true; + } else { + mainLeds[physicalIdx] = encodeForStrip(globalIdx, color); + mainLedsDirty = true; + } +} + +inline CRGB readLed(uint8_t globalIdx) { + return logicalLeds[globalIdx]; +} + +void showDirtyLeds() { + if (mainLedsDirty && mainLedController != nullptr) { + mainLedController->showLeds(255); + mainLedsDirty = false; + } + if (indicatorLedsDirty && indicatorLedController != nullptr) { + indicatorLedController->showLeds(255); + indicatorLedsDirty = false; + } +} + +// 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 +// VFD +// 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. +// ERR BAD_VFD +// Sent by VFD when the text payload is malformed. +// 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 String& s) { + CMD_PORT.println(s); +} + +// Tiny float absolute-value helper to avoid dragging more machinery into the sketch. +float absf(float x) { + return (x < 0.0f) ? -x : x; +} + +// 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; + + int8_t pin = gaugePins[id].enablePin; + if (pin < 0) return; + + bool level = gaugePins[id].enableActiveLow ? !en : en; + digitalWrite(pin, level ? HIGH : LOW); +} + +// Applies the logical direction after accounting for per-gauge inversion. +void setDir(uint8_t id, bool forward) { + bool level = gaugePins[id].dirInverted ? !forward : forward; + digitalWrite(gaugePins[id].dirPin, level ? HIGH : LOW); +} + +// Emits one step pulse with the polarity expected by the driver. +void pulseStep(uint8_t id) { + bool active = gaugePins[id].stepActiveHigh; + digitalWrite(gaugePins[id].stepPin, active ? HIGH : LOW); + delayMicroseconds(4); + digitalWrite(gaugePins[id].stepPin, active ? LOW : HIGH); +} + +// Moves the motor by one step if the requested direction is still within allowed travel. +void doStep(uint8_t id, int dir, bool allowPastMin = false) { + Gauge& g = gauges[id]; + if (!g.enabled) return; + + if (dir > 0) { + if (g.currentPos >= g.maxPos) return; + setDir(id, true); + pulseStep(id); + g.currentPos++; + } else if (dir < 0) { + if (!allowPastMin && g.currentPos <= g.minPos) return; + setDir(id, false); + pulseStep(id); + g.currentPos--; + } +} + +// 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]; + g.homingState = HS_START; + g.homed = false; + g.velocity = 0.0f; + g.stepAccumulator = 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) { + Gauge& g = gauges[id]; + unsigned long nowUs = micros(); + unsigned long nowMs = millis(); + + 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.stepAccumulator = 0.0f; + g.homingStepsRemaining = g.homingBackoffSteps; + g.homingLastStepMicros = nowUs; + g.homingState = HS_BACKING; + break; + + case HS_BACKING: { + float intervalUs = 1000000.0f / g.homingSpeed; + if ((nowUs - g.homingLastStepMicros) >= intervalUs) { + g.homingLastStepMicros = nowUs; + + if (g.homingStepsRemaining > 0) { + doStep(id, -1, true); + g.homingStepsRemaining--; + } else { + 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; + g.stepAccumulator = 0.0f; + g.homed = true; + g.homingState = HS_DONE; + + DEBUG_PORT.print("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) { + Gauge& g = gauges[id]; + + if (g.homingState != HS_IDLE) { + updateHoming(id); + return; + } + + if (!g.homed) return; + + if (g.sweepEnabled) { + updateSweepTarget(id); + } + + unsigned long now = micros(); + if (g.lastUpdateMicros == 0) { + g.lastUpdateMicros = now; + return; + } + + float dt = (now - g.lastUpdateMicros) / 1000000.0f; + g.lastUpdateMicros = now; + + 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; + g.stepAccumulator = 0.0f; + 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 brakingDistance = (g.velocity * g.velocity) / (2.0f * g.accel + 0.0001f); + + if ((float)labs(error) <= brakingDistance) { + if (g.velocity > 0.0f) { + g.velocity -= g.accel * dt; + if (g.velocity < 0.0f) g.velocity = 0.0f; + } else if (g.velocity < 0.0f) { + g.velocity += g.accel * dt; + if (g.velocity > 0.0f) g.velocity = 0.0f; + } + } else { + g.velocity += dir * g.accel * dt; + if (g.velocity > g.maxSpeed) g.velocity = g.maxSpeed; + if (g.velocity < -g.maxSpeed) g.velocity = -g.maxSpeed; + } + + if (fabs(g.velocity) < 0.01f && error != 0) { + g.velocity = dir * 5.0f; + } + + // Integrate fractional steps until there is enough to emit a real pulse. + g.stepAccumulator += g.velocity * dt; + + while (g.stepAccumulator >= 1.0f) { + if (g.currentPos == g.targetPos) { + g.stepAccumulator = 0.0f; + g.velocity = 0.0f; + break; + } + + doStep(id, +1, false); + g.stepAccumulator -= 1.0f; + + if (g.currentPos >= g.maxPos) { + g.currentPos = g.maxPos; + g.targetPos = g.maxPos; + g.velocity = 0.0f; + g.stepAccumulator = 0.0f; + break; + } + } + + while (g.stepAccumulator <= -1.0f) { + if (g.currentPos == g.targetPos) { + g.stepAccumulator = 0.0f; + g.velocity = 0.0f; + break; + } + + doStep(id, -1, false); + g.stepAccumulator += 1.0f; + + if (g.currentPos <= g.minPos) { + g.currentPos = g.minPos; + g.targetPos = g.minPos; + g.velocity = 0.0f; + g.stepAccumulator = 0.0f; + break; + } + } +} + +// Parses `SET ` and updates the target position. +// Replies: `OK`, `ERR BAD_ID`. +bool parseSet(const String& line) { + int id; + long pos; + if (sscanf(line.c_str(), "SET %d %ld", &id, &pos) == 2) { + if (id < 0 || id >= GAUGE_COUNT) { + sendReply("ERR BAD_ID"); + return true; + } + + Gauge& g = gauges[id]; + pos = constrain(pos, g.minPos, g.maxPos); + g.targetPos = pos; + g.sweepEnabled = false; + sendReply("OK"); + return true; + } + return false; +} + +// Parses `SPEED ` and updates the max step rate. +// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_SPEED`. +bool parseSpeed(const String& line) { + int firstSpace = line.indexOf(' '); + int secondSpace = line.indexOf(' ', firstSpace + 1); + if (firstSpace < 0 || secondSpace < 0) return false; + if (line.substring(0, firstSpace) != "SPEED") return false; + + int id = line.substring(firstSpace + 1, secondSpace).toInt(); + float speed = line.substring(secondSpace + 1).toFloat(); + + if (id < 0 || id >= GAUGE_COUNT) { + sendReply("ERR BAD_ID"); + return true; + } + if (speed <= 0.0f) { + sendReply("ERR BAD_SPEED"); + return true; + } + + gauges[id].maxSpeed = speed; + sendReply("OK"); + return true; +} + +// Parses `ACCEL ` and updates the acceleration limit. +// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_ACCEL`. +bool parseAccel(const String& line) { + int firstSpace = line.indexOf(' '); + int secondSpace = line.indexOf(' ', firstSpace + 1); + if (firstSpace < 0 || secondSpace < 0) return false; + if (line.substring(0, firstSpace) != "ACCEL") return false; + + int id = line.substring(firstSpace + 1, secondSpace).toInt(); + float accel = line.substring(secondSpace + 1).toFloat(); + + if (id < 0 || id >= GAUGE_COUNT) { + sendReply("ERR BAD_ID"); + return true; + } + if (accel <= 0.0f) { + sendReply("ERR BAD_ACCEL"); + return true; + } + + gauges[id].accel = accel; + sendReply("OK"); + return true; +} + +// Parses `ENABLE <0|1>` and toggles the selected driver. +// Replies: `OK`, `ERR BAD_ID`. +bool parseEnable(const String& line) { + int id, en; + if (sscanf(line.c_str(), "ENABLE %d %d", &id, &en) == 2) { + if (id < 0 || id >= GAUGE_COUNT) { + sendReply("ERR BAD_ID"); + return true; + } + + setEnable(id, en != 0); + sendReply("OK"); + return true; + } + return false; +} + +// Parses `ZERO ` and declares the current position to be home. +// Replies: `OK`, `ERR BAD_ID`. +bool parseZero(const String& line) { + int id; + if (sscanf(line.c_str(), "ZERO %d", &id) == 1) { + if (id < 0 || id >= GAUGE_COUNT) { + sendReply("ERR BAD_ID"); + return true; + } + + Gauge& g = gauges[id]; + g.currentPos = 0; + g.targetPos = 0; + g.velocity = 0.0f; + g.stepAccumulator = 0.0f; + g.homed = true; + g.sweepEnabled = false; + sendReply("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 String& line) { + int id; + if (sscanf(line.c_str(), "HOME %d", &id) == 1) { + if (id < 0 || id >= GAUGE_COUNT) { + sendReply("ERR BAD_ID"); + return true; + } + + requestHome(id); + sendReply("OK"); + return true; + } + + if (line == "HOMEALL") { + requestHomeAll(); + sendReply("OK"); + return true; + } + + return false; +} + +// Parses `SWEEP ` and enables or disables end-to-end motion. +// Replies: `OK`, `ERR BAD_ID`. +bool parseSweep(const String& line) { + int firstSpace = line.indexOf(' '); + int secondSpace = line.indexOf(' ', firstSpace + 1); + int thirdSpace = line.indexOf(' ', secondSpace + 1); + + if (firstSpace < 0 || secondSpace < 0 || thirdSpace < 0) return false; + if (line.substring(0, firstSpace) != "SWEEP") return false; + + int id = line.substring(firstSpace + 1, secondSpace).toInt(); + float accel = line.substring(secondSpace + 1, thirdSpace).toFloat(); + float speed = line.substring(thirdSpace + 1).toFloat(); + + if (id < 0 || id >= GAUGE_COUNT) { + sendReply("ERR BAD_ID"); + return true; + } + + Gauge& g = gauges[id]; + + if (accel <= 0.0f || speed <= 0.0f) { + g.sweepEnabled = false; + g.velocity = 0.0f; + g.stepAccumulator = 0.0f; + sendReply("OK"); + return true; + } + + g.accel = accel; + g.maxSpeed = speed; + g.sweepEnabled = true; + g.sweepTowardMax = true; + g.targetPos = g.maxPos; + sendReply("OK"); + return true; +} + +// Answers `POS?` with current motion state for every gauge. +// Emits one `POS ` line per gauge, +// then replies `OK`. +bool parsePosQuery(const String& line) { + if (line == "POS?") { + for (uint8_t i = 0; i < GAUGE_COUNT; i++) { + CMD_PORT.print("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("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 String& line) { + if (line == "CFG?") { + for (uint8_t i = 0; i < GAUGE_COUNT; i++) { + CMD_PORT.print("CFG "); + CMD_PORT.print(i); + CMD_PORT.print(' '); + CMD_PORT.print((int)gauges[i].maxSpeed); + CMD_PORT.print(' '); + CMD_PORT.println((int)gauges[i].accel); + } + sendReply("OK"); + return true; + } + return false; +} + +// Answers the mandatory life question: are you there? +// Reply: `PONG`. +bool parsePing(const String& line) { + if (line == "PING") { + sendReply("PONG"); + return true; + } + return false; +} + +// Parses `VFD ` where is up to four hex characters with optional `.` and `!` suffixes. +// Replies: `OK`, `ERR BAD_VFD`. +bool parseVfd(const String& line) { + if (line == "VFD") { + vfd::clear(); + sendReply("OK"); + return true; + } + + if (!line.startsWith("VFD ")) return false; + + const String payload = line.substring(4); + if (payload.length() == 0) { + vfd::clear(); + sendReply("OK"); + return true; + } + + if (vfd::parseCommand(payload)) { + sendReply("OK"); + } else { + sendReply("ERR BAD_VFD"); + } + return true; +} + +// Answers `LED?` with the current RGB values for every configured LED. +// Emits one `LED ` line per configured LED, then replies `OK`. +bool parseLedQuery(const String& line) { + if (line == "LED?") { + 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("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("OK"); + return true; + } + return false; +} + +// Parses `LED ` and writes static colours. +// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`. +bool parseLed(const String& line) { + int id, r, g, b; + char idxToken[16]; + if (sscanf(line.c_str(), "LED %d %15s %d %d %d", &id, idxToken, &r, &g, &b) == 5) { + if (id < 0 || id >= GAUGE_COUNT) { sendReply("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("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("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 String& 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.c_str(), "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("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("ERR BAD_IDX"); return true; + } + + if (onMs == 0 && offMs == 0) { + for (int i = idxFirst; i <= idxLast; i++) + blinkState[gaugeLedOffset[id] + i].active = false; + sendReply("OK"); + return true; + } + if (onMs <= 0 || offMs <= 0) { sendReply("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("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 String& line) { + int id, periodMs, r, g, b; + char idxToken[16]; + if (sscanf(line.c_str(), "BREATHE %d %15s %d %d %d %d", + &id, idxToken, &periodMs, &r, &g, &b) != 6) return false; + if (id < 0 || id >= GAUGE_COUNT) { sendReply("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("ERR BAD_IDX"); return true; + } + if (periodMs <= 0) { sendReply("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("OK"); + return true; +} + +// Parses `DFLASH ...` and assigns the double-flash pattern. +// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`. +bool parseDflash(const String& line) { + int id, r, g, b; + char idxToken[16]; + if (sscanf(line.c_str(), "DFLASH %d %15s %d %d %d", + &id, idxToken, &r, &g, &b) != 5) return false; + if (id < 0 || id >= GAUGE_COUNT) { sendReply("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("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("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 String& 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 (parseVfd(line)) return; + if (parsePing(line)) return; + + sendReply("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') { + rxLine.trim(); + if (rxLine.length() > 0) { + processLine(rxLine); + } + rxLine = ""; + } else if (c != '\r') { + rxLine += c; + if (rxLine.length() > 120) { + rxLine = ""; + sendReply("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("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); + } + + gauges[i].lastUpdateMicros = micros(); + } + + // Flatten the per-gauge LED counts into logical offsets and separate + // physical offsets for the main and indicator strips. + uint8_t ledOff = 0; + uint8_t mainLedOff = 0; + uint8_t indicatorLedOff = 0; + for (uint8_t i = 0; i < GAUGE_COUNT; i++) { + gaugeLedCount[i] = cstrLen(gaugePins[i].ledOrder); + gaugeLedOffset[i] = ledOff; + gaugeMainLedOffset[i] = mainLedOff; + gaugeIndicatorLedOffset[i] = indicatorLedOff; + ledOff += gaugeLedCount[i]; + indicatorLedOff += countIndicatorLedsForGauge(i); + mainLedOff += gaugeLedCount[i] - countIndicatorLedsForGauge(i); + } + mainLedController = &FastLED.addLeds(mainLeds, TOTAL_MAIN_LEDS); + indicatorLedController = &FastLED.addLeds(indicatorLeds, TOTAL_INDICATOR_LEDS); + FastLED.setBrightness(255); + mainLedController->showLeds(255); + indicatorLedController->showLeds(255); + + vfd::begin(); + + requestHomeAll(); + + DEBUG_PORT.println("READY"); + // Boot-complete handshake for the command channel. + sendReply("READY"); +} + +// Main service loop: ingest commands, advance effects, move gauges, flush LEDs. +void loop() { + readCommands(); + vfd::refresh(); + updateBlink(); + + for (uint8_t i = 0; i < GAUGE_COUNT; i++) { + updateGauge(i); + } + + showDirtyLeds(); + + +}