381 lines
17 KiB
C++
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;
|
|
}
|
|
};
|