#pragma once // LedUI — RGB-LED status feedback for screenless boards (AtomS3 Lite). // // The universal firmware binary runs on both the AtomS3 (128x128 LCD) and // the AtomS3 Lite (no LCD, one SK6812 RGB LED on GPIO 35, driven by // M5Unified's M5.Led which M5.begin() wires up automatically from the // board pin table). DisplayUI gates every screen call on the Lite and // forwards a semantic state here instead, so macro_engine.h and // MacroPad.ino never have to know which board they're on. // // Design rules: // // 1. Non-blocking. tick() is called every main-loop iteration and // computes the LED color from millis(); no delay() anywhere. The // strip is only rewritten when the computed color changes (an RMT // refresh per loop would be pure waste). // // 2. Idempotent state setters. Many display calls repaint every engine // tick (showDelayProgress, showPauseScreen, showFailScreen). Setting // the same base pattern again must NOT reset the blink phase, or the // LED would freeze at "on". setBase() compares against the current // pattern and keeps the phase when nothing changed. // // 3. Base + overlay. The base pattern is the persistent state (idle // color, executing, live mode). An overlay is a short transient // (click flash, position burst, alive-check result) that plays once // and reveals the base again. Overlays never change the base. // // LED vocabulary (see the project README for the user-facing table). Every // state is a distinct (color, motion) pair; smooth crossfades mark the calm // "what am I / who has me" states, sharper blinks and bursts mark action: // boot white single pulse // idle — BLE soft white<->blue crossfade (transport at rest) // idle — mesh soft white<->amber crossfade (transport at rest) // ...both: a click adds a white flash + N-blink slot count // mode switched triple burst in the new accent (blue BLE / amber mesh) // executing steady green; typing ramps brightness with progress; // delay nodes breathe green // pause steady yellow (untimed) / yellow blink, 1 Hz // accelerating to 4 Hz in the last 3 s (timed) // branch selector magenta burst, count = selected choice + 1, repeating // loop selector orange burst, count = current value (capped at 10) // error red triple-blink repeating // fail wait red blink (steady red if paused) // live joined slow cyan breathe (heartbeat); keystrokes = white flicker // reconnecting cyan 1 Hz blink (seeking the host) // lagging/lost hub cyan/red 2 Hz alternating // identify fast blue/white strobe (which physical unit is this) // hub mode steady purple // BLE variables blue breathe (on-demand exchange inside a routine) // host probe fast white blink; waiting-on-user = slow white blink #include class LedUI { public: void begin(bool enabled) { _enabled = enabled; if (!_enabled) return; M5.Led.setBrightness(255); // we scale in software per-pattern setBase(Mode::OFF, 0, 0, 0); } bool enabled() const { return _enabled; } // Palette (0xRRGGBB) — one place to tune the whole vocabulary. Each hue // owns a phase: green = running, red = failure, yellow = pause, magenta = // branch, orange = loop, cyan = live session, purple = hub, and the two // transport accents (blue = BLE, amber = mesh) morph out of white. static constexpr uint32_t COL_WHITE = 0xFFFFFF; static constexpr uint32_t COL_BLE = 0x0060FF; // BLE transport accent static constexpr uint32_t COL_MESH = 0xFFB000; // ESP-NOW mesh accent (amber) static constexpr uint32_t COL_GREEN = 0x00FF00; // executing / running static constexpr uint32_t COL_RED = 0xFF0000; // error / failure static constexpr uint32_t COL_YELLOW = 0xFFDD00; // pause node static constexpr uint32_t COL_MAGENTA = 0xFF00FF; // branch selector static constexpr uint32_t COL_ORANGE = 0xFF6000; // loop selector static constexpr uint32_t COL_CYAN = 0x00E0FF; // live session (joined) static constexpr uint32_t COL_PURPLE = 0x9000FF; // hub mode // Called every main-loop iteration. Cheap when nothing changes. void tick() { if (!_enabled) return; uint32_t now = millis(); uint32_t rgb; if (_ovActive) { if (now - _ovStartMs >= _ovDurationMs) { _ovActive = false; rgb = _baseColorAt(now); } else { rgb = _patternColorAt(_ov, now - _ovStartMs); } } else { rgb = _baseColorAt(now); } if (rgb != _lastWritten) { _lastWritten = rgb; M5.Led.setAllColor((uint8_t)(rgb >> 16), (uint8_t)(rgb >> 8), (uint8_t)rgb); } } // --------------------------------------------------------------------- // Semantic states (all no-ops when disabled) // --------------------------------------------------------------------- void off() { setBase(Mode::OFF, 0, 0, 0); } void boot() { setBase(Mode::OFF, 0, 0, 0); overlayPulse(0xFFFFFF, 35, 800); } // Idle selector / listening. The persistent base is a soft crossfade in // the device's live transport: white<->blue = BLE, white<->amber = ESP-NOW // mesh, so a resting headless node shows which mode it's in at a glance. // A click/redraw still plays a white flash + position burst so the user // can count which slot they're on. void macroSelector(int displayIdx, bool bleMode) { if (!_enabled) return; setBase(Mode::FADE, COL_WHITE, 45, 2600, 0, bleMode ? COL_BLE : COL_MESH); int blinks = (displayIdx % 5) + 1; overlayBurst(COL_WHITE, 70, blinks, 90, 120); } // Running a routine: steady green. typing() brightens it with progress // (dim -> bright as the string types); a delay node breathes it. All three // are "green = running", distinguished by motion. void executing() { setBase(Mode::STEADY, COL_GREEN, 60); } void typing(int charIdx, int len) { if (len < 1) len = 1; if (charIdx < 0) charIdx = 0; if (charIdx >= len) charIdx = len - 1; uint8_t scale = 20 + (uint8_t)((80 * charIdx) / len); setBase(Mode::STEADY, COL_GREEN, scale); } void breathe() { setBase(Mode::BREATHE, COL_GREEN, 60, 2000); } // Pause node: yellow. Steady = untimed (waiting on a click); blink that // accelerates as the timer runs out = timed. void pauseScreen(bool timed, uint32_t remainMs) { if (!timed) { setBase(Mode::STEADY, COL_YELLOW, 60); } else if (remainMs > 3000) { setBase(Mode::BLINK, COL_YELLOW, 60, 500, 500); } else { setBase(Mode::BLINK, COL_YELLOW, 60, 125, 125); } } // Selectors count with blink-bursts: magenta = branch choice, orange = // loop value. Both are the burst count + 1s gap, repeating. void branchSelector(int selectedIdx) { if (selectedIdx < 0) selectedIdx = 0; setBaseBurst(COL_MAGENTA, 60, (uint8_t)(selectedIdx + 1), 1000); } void iterationBranch(int pathIdx) { if (!_enabled) return; overlayBurst(COL_MAGENTA, 60, (uint8_t)((pathIdx < 0 ? 0 : pathIdx) + 1), 120, 150); } void loopSelector(int value) { if (value < 1) value = 1; if (value > 10) value = 10; setBaseBurst(COL_ORANGE, 60, (uint8_t)value, 1000); } // Error = red triple-blink. Resume countdown = fast green blink ("about to // auto-run a routine; hold to cancel") — green, not yellow, so it can't be // mistaken for a timed pause. void errorPattern() { setBaseBurst(COL_RED, 80, 3, 700); } void resumeCountdown(){ setBase(Mode::BLINK, COL_GREEN, 70, 150, 150); } // Live-session states, all cyan-based and told apart by motion: // reconnecting cyan blink (seeking the host after a power loss) // liveIdle slow cyan breathe (joined & ready — a live "heartbeat") // lagging cyan<->red alt (in a session but losing the hub) void reconnecting() { setBase(Mode::BLINK, COL_CYAN, 55, 500, 500); } void liveIdle() { setBase(Mode::BREATHE, COL_CYAN, 45, 3200); } void lagging() { setBase(Mode::ALT, COL_CYAN, 60, 250, 250, COL_RED); } // Identify ("which physical unit is this?") — a fast, deliberate // white<->blue strobe, unmistakable against the slow BLE idle fade. void identify() { setBase(Mode::ALT, COL_BLE, 70, 160, 160, COL_WHITE); } void hubMode() { setBase(Mode::STEADY, COL_PURPLE, 60); } // Live-transport toggle confirmation on a screenless Lite: a triple burst // in the NEW mode's accent (blue = BLE, amber = mesh), matching the idle // crossfade the node will now rest in. void modeSwitch(bool ble) { setBaseBurst(ble ? COL_BLE : COL_MESH, 75, 3, 500); } // White blinks: slow = waiting on the user (RS232/pause prompts), // fast = actively probing the host (Num Lock alive check). void waiting() { setBase(Mode::BLINK, COL_WHITE, 45, 500, 500); } void probe() { setBase(Mode::BLINK, COL_WHITE, 50, 100, 100); } // On-demand BLE variables exchange inside a routine: a blue breathe // ("working on Bluetooth") — distinct from the BLE idle white<->blue fade. void bleStatus() { setBase(Mode::BREATHE, COL_BLE, 50, 1400); } void failWait(bool paused) { if (paused) setBase(Mode::STEADY, COL_RED, 40); else setBase(Mode::BLINK, COL_RED, 60, 250, 250); } // Short white flicker over the live base — played as keystrokes drain so // the user can see traffic flowing on a screenless node. void liveActivity() { overlayPulse(COL_WHITE, 60, 30); } // Generic per-node transient (key combo, mouse, media key, sub-call...) void activityPulse(uint16_t color565) { overlayPulse(from565(color565), 60, 90); } void aliveResult(bool ok) { overlayBurst(ok ? COL_GREEN : COL_RED, 80, 2, 100, 120); } // showMessage mapping: red = persistent error pattern, anything else a // steady dim tint (covers "No Macros", boot status text, etc.). void message(uint16_t color565) { if (color565 == TFT_RED) errorPattern(); else setBase(Mode::STEADY, from565(color565), 35); } private: enum class Mode : uint8_t { OFF, STEADY, BLINK, ALT, BURST, BREATHE, FADE }; struct Pattern { Mode mode = Mode::OFF; uint32_t rgb = 0; // primary color, 0xRRGGBB uint32_t rgb2 = 0; // ALT second color uint8_t scale = 100; // brightness percent uint16_t onMs = 0; // BLINK/ALT phase length; BREATHE period uint16_t offMs = 0; uint8_t count = 0; // BURST blink count uint16_t gapMs = 0; // BURST gap after the blinks }; bool _enabled = false; Pattern _base; uint32_t _baseStartMs = 0; Pattern _ov; bool _ovActive = false; uint32_t _ovStartMs = 0; uint32_t _ovDurationMs = 0; uint32_t _lastWritten = 0xFFFFFFFF; // sentinel forces first write static uint32_t from565(uint16_t c) { uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3); uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2); uint8_t b = (uint8_t)((c & 0x1F) << 3); return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b; } static uint32_t scaleRgb(uint32_t rgb, uint8_t pct) { uint8_t r = (uint8_t)((((rgb >> 16) & 0xFF) * pct) / 100); uint8_t g = (uint8_t)((((rgb >> 8) & 0xFF) * pct) / 100); uint8_t b = (uint8_t)(((rgb & 0xFF) * pct) / 100); return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b; } // Linear per-channel blend: f=0 -> c1, f=100 -> c2. Used by FADE for a // smooth crossfade between two colors (e.g. white <-> blue). static uint32_t mix(uint32_t c1, uint32_t c2, uint32_t f) { if (f > 100) f = 100; uint32_t g = 100 - f; uint8_t r = (uint8_t)((((c1 >> 16) & 0xFF) * g + ((c2 >> 16) & 0xFF) * f) / 100); uint8_t gr = (uint8_t)((((c1 >> 8) & 0xFF) * g + ((c2 >> 8) & 0xFF) * f) / 100); uint8_t b = (uint8_t)(((c1 & 0xFF) * g + (c2 & 0xFF) * f) / 100); return ((uint32_t)r << 16) | ((uint32_t)gr << 8) | b; } static bool samePattern(const Pattern& a, const Pattern& b) { return a.mode == b.mode && a.rgb == b.rgb && a.rgb2 == b.rgb2 && a.scale == b.scale && a.onMs == b.onMs && a.offMs == b.offMs && a.count == b.count && a.gapMs == b.gapMs; } void setBase(Mode mode, uint32_t rgb, uint8_t scale, uint16_t onMs = 0, uint16_t offMs = 0, uint32_t rgb2 = 0) { if (!_enabled) return; Pattern p; p.mode = mode; p.rgb = rgb; p.rgb2 = rgb2; p.scale = scale; p.onMs = onMs; p.offMs = offMs; if (samePattern(p, _base)) return; // keep blink phase _base = p; _baseStartMs = millis(); } void setBaseBurst(uint32_t rgb, uint8_t scale, uint8_t count, uint16_t gapMs) { if (!_enabled) return; Pattern p; p.mode = Mode::BURST; p.rgb = rgb; p.scale = scale; p.onMs = 120; p.offMs = 150; p.count = count; p.gapMs = gapMs; if (samePattern(p, _base)) return; _base = p; _baseStartMs = millis(); } void overlayPulse(uint32_t rgb, uint8_t scale, uint16_t durMs) { if (!_enabled) return; _ov.mode = Mode::STEADY; _ov.rgb = rgb; _ov.scale = scale; _ovActive = true; _ovStartMs = millis(); _ovDurationMs = durMs; } void overlayBurst(uint32_t rgb, uint8_t scale, uint8_t count, uint16_t onMs, uint16_t offMs) { if (!_enabled) return; _ov.mode = Mode::BURST; _ov.rgb = rgb; _ov.scale = scale; _ov.onMs = onMs; _ov.offMs = offMs; _ov.count = count; _ov.gapMs = 0; _ovActive = true; _ovStartMs = millis(); _ovDurationMs = (uint32_t)count * (onMs + offMs); } uint32_t _baseColorAt(uint32_t now) { return _patternColorAt(_base, now - _baseStartMs); } uint32_t _patternColorAt(const Pattern& p, uint32_t t) { switch (p.mode) { case Mode::OFF: return 0; case Mode::STEADY: return scaleRgb(p.rgb, p.scale); case Mode::BLINK: { uint32_t period = (uint32_t)p.onMs + p.offMs; if (period == 0) return scaleRgb(p.rgb, p.scale); return (t % period) < p.onMs ? scaleRgb(p.rgb, p.scale) : 0; } case Mode::ALT: { uint32_t period = (uint32_t)p.onMs + p.offMs; if (period == 0) return scaleRgb(p.rgb, p.scale); return (t % period) < p.onMs ? scaleRgb(p.rgb, p.scale) : scaleRgb(p.rgb2, p.scale); } case Mode::BURST: { uint32_t blinkLen = (uint32_t)p.onMs + p.offMs; uint32_t period = (uint32_t)p.count * blinkLen + p.gapMs; if (period == 0) return 0; uint32_t ph = t % period; if (ph >= (uint32_t)p.count * blinkLen) return 0; // gap return (ph % blinkLen) < p.onMs ? scaleRgb(p.rgb, p.scale) : 0; } case Mode::BREATHE: { // Triangle wave between 10% and the pattern's scale. uint32_t period = p.onMs ? p.onMs : 2000; uint32_t ph = t % period; uint32_t half = period / 2; uint32_t frac100 = (ph < half) ? (ph * 100) / half : ((period - ph) * 100) / half; uint8_t lo = 10; uint8_t span = (p.scale > lo) ? (p.scale - lo) : 0; uint8_t s = lo + (uint8_t)((span * frac100) / 100); return scaleRgb(p.rgb, s); } case Mode::FADE: { // Smooth crossfade rgb <-> rgb2 on a triangle wave (period in // onMs). Constant brightness (scale) — only the hue morphs. uint32_t period = p.onMs ? p.onMs : 2600; uint32_t ph = t % period; uint32_t half = period / 2; uint32_t f = (ph < half) ? (ph * 100) / half : ((period - ph) * 100) / half; return scaleRgb(mix(p.rgb, p.rgb2, f), p.scale); } } return 0; } };