Initial public release
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
#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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user