Files
m5stack-automation-tool/firmware/MacroPad/led_ui.h
T
2026-07-17 15:29:53 -04:00

381 lines
17 KiB
C++

#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 <M5Unified.h>
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;
}
};