// This sketch controls a 512-pixel NeoPixel display using 6 buttons.
// When a button is pressed, a custom-colored text message is displayed for 5 seconds.
// This version uses pixel-level Linear Interpolation (LERP) to ensure perfectly smooth,
// non-flashing transitions between any two states (Message A, Message B, or Blank).
#include <Adafruit_NeoPixel.h>
#include <Bounce2.h>
// --- Configuration ---
#define LED_PIN 4
#define LED_COUNT 512
#define DISPLAY_DURATION_MS 5000UL // Message hold time (5 seconds)
const unsigned long FADE_DURATION_MS = 300UL; // Fade time (0.3 seconds, reduced)
const unsigned long READY_HOLD_MS = 1000UL; // READY message hold time (1 second)
// Matrix Dimensions
const int MATRIX_WIDTH = 64;
const int MATRIX_HEIGHT = 8;
const int PANEL_SIZE = 64; // 8x8 panel = 64 pixels
// Button pins (using INPUT_PULLUP)
const int BUTTON_PINS[] = {42, 41, 40, 39, 38, 37};
const int NUM_BUTTONS = 6;
Bounce buttons[NUM_BUTTONS];
const int DEBOUNCE_DELAY_MS = 30;
// --- Pixel LERP Buffers ---
// These arrays hold the full color data (uint32_t) for 512 pixels.
uint32_t TargetDisplay[LED_COUNT]; // Destination colors for the current fade
uint32_t SourceDisplay[LED_COUNT]; // Starting colors for the current fade
// --- Fading and Timing State ---
enum DisplayState {
STATE_IDLE,
STATE_HOLD, // Full display time after fade-in
STATE_TRANSITION // Actively LERPing between Source and Target arrays
};
DisplayState displayState = STATE_IDLE;
unsigned long displayStartTime = 0; // When the message began its hold phase
unsigned long transitionStartTime = 0; // When the current LERP started
// --- 1. Message Variables & Brightness ---
// NeoPixel Brightness Level (0-255). This is the effective maximum intensity.
const uint8_t BRIGHTNESS_LEVEL = 255;
// All messages are forced to Red for consistency.
String MESSAGE_1 = "<#FF0000>Button 1";
String MESSAGE_2 = "<#FF0000>Button 2";
String MESSAGE_3 = "<#FF0000>Button 3";
String MESSAGE_4 = "<#FF0000>Button 4";
String MESSAGE_5 = "<#FF0000>Button 5";
String MESSAGE_6 = "<#FF0000>Button 6";
const String READY_MESSAGE = "<#FF0000>READY";
const String BLANK_MESSAGE = ""; // Used to signal fade-to-blank
// Stores the currently requested message (or BLANK_MESSAGE)
String currentMessage = BLANK_MESSAGE;
Adafruit_NeoPixel pixels(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
// --- 2. Font Definition (A simple 5x7 bitmap font) ---
// Font definition content remains the same...
const uint8_t PROGMEM font5x7[][5] = {
// Space (0x20)
{0x00, 0x00, 0x00, 0x00, 0x00},
// ! (0x21)
{0x00, 0x00, 0x5F, 0x00, 0x00},
// " (0x22)
{0x00, 0x07, 0x00, 0x07, 0x00},
// # (0x23)
{0x14, 0x7F, 0x14, 0x7F, 0x14},
// $ (0x24)
{0x24, 0x2A, 0x7F, 0x2A, 0x12},
// % (0x25)
{0x23, 0x13, 0x08, 0x64, 0x63},
// & (0x26)
{0x36, 0x49, 0x55, 0x22, 0x50},
// ' (0x27)
{0x00, 0x05, 0x03, 0x00, 0x00},
// ( (0x28)
{0x00, 0x1C, 0x22, 0x41, 0x00},
// ) (0x29)
{0x00, 0x41, 0x22, 0x1C, 0x00},
// * (0x2A)
{0x20, 0x10, 0x08, 0x10, 0x20},
// + (0x2B)
{0x10, 0x10, 0x7C, 0x10, 0x10},
// , (0x2C)
{0x00, 0x5E, 0x30, 0x00, 0x00},
// - (0x2D)
{0x00, 0x00, 0x7C, 0x00, 0x00},
// . (0x2E)
{0x00, 0x30, 0x30, 0x00, 0x00},
// / (0x2F)
{0x00, 0x42, 0x24, 0x18, 0x00},
// 0 (0x30)
{0x3E, 0x51, 0x49, 0x45, 0x3E},
// 1 (0x31)
{0x00, 0x42, 0x7F, 0x40, 0x00},
// 2 (0x32)
{0x62, 0x51, 0x49, 0x49, 0x46},
// 3 (0x33)
{0x22, 0x41, 0x49, 0x49, 0x36},
// 4 (0x34)
{0x18, 0x14, 0x12, 0x7F, 0x10},
// 5 (0x35)
{0x27, 0x45, 0x45, 0x45, 0x39},
// 6 (0x36)
{0x3C, 0x4A, 0x49, 0x49, 0x30},
// 7 (0x37)
{0x41, 0x7F, 0x48, 0x48, 0x48},
// 8 (0x38)
{0x36, 0x49, 0x49, 0x49, 0x36},
// 9 (0x39)
{0x06, 0x49, 0x49, 0x29, 0x1E},
// : (0x3A)
{0x00, 0x36, 0x36, 0x00, 0x00},
// ; (0x3B)
{0x00, 0x5E, 0x36, 0x00, 0x00},
// < (0x3C)
{0x10, 0x20, 0x40, 0x20, 0x10},
// = (0x3D)
{0x00, 0x00, 0x7E, 0x00, 0x00},
// > (0x3E)
{0x10, 0x20, 0x40, 0x20, 0x10},
// ? (0x3F)
{0x20, 0x50, 0x50, 0x7C, 0x10},
// @ (0x40)
{0x36, 0x49, 0x55, 0x22, 0x50},
// A (0x41)
{0x7E, 0x09, 0x09, 0x09, 0x7E},
// B (0x42)
{0x7F, 0x49, 0x49, 0x49, 0x36},
// C (0x43)
{0x3E, 0x41, 0x41, 0x41, 0x22},
// D (0x44)
{0x7F, 0x41, 0x41, 0x22, 0x1C},
// E (0x45)
{0x7F, 0x49, 0x49, 0x49, 0x41},
// F (0x46)
{0x7F, 0x09, 0x09, 0x01, 0x01},
// G (0x47)
{0x3E, 0x41, 0x49, 0x49, 0x7A},
// H (0x48)
{0x7F, 0x08, 0x08, 0x08, 0x7F},
// I (0x49)
{0x00, 0x41, 0x7F, 0x41, 0x00},
// J (0x4A)
{0x20, 0x40, 0x41, 0x3F, 0x01},
// K (0x4B)
{0x7F, 0x08, 0x14, 0x22, 0x41},
// L (0x4C)
{0x7F, 0x40, 0x40, 0x40, 0x40},
// M (0x4D)
{0x7F, 0x02, 0x04, 0x02, 0x7F},
// N (0x4E)
{0x7F, 0x04, 0x08, 0x10, 0x7F},
// O (0x4F)
{0x3E, 0x41, 0x41, 0x41, 0x3E},
// P (0x50)
{0x7F, 0x09, 0x09, 0x09, 0x06},
// Q (0x51)
{0x3E, 0x41, 0x51, 0x21, 0x5E},
// R (0x52)
{0x7F, 0x09, 0x19, 0x29, 0x46},
// S (0x53)
{0x46, 0x49, 0x49, 0x49, 0x31},
// T (0x54)
{0x01, 0x01, 0x7F, 0x01, 0x01},
// U (0x55)
{0x3F, 0x40, 0x40, 0x40, 0x3F},
// V (0x56)
{0x1F, 0x20, 0x40, 0x20, 0x1F},
// W (0x57)
{0x3F, 0x40, 0x38, 0x40, 0x3F},
// X (0x58)
{0x63, 0x14, 0x08, 0x14, 0x63},
// Y (0x59)
{0x07, 0x08, 0x70, 0x08, 0x07},
// Z (0x5A)
{0x61, 0x51, 0x49, 0x45, 0x43}
};
// --- 3. Core Display Drawing Functions ---
/**
* @brief Linearly interpolates (LERP) between two 32-bit NeoPixel colors.
* The ratio is clamped between 0.0 and 1.0.
*/
uint32_t lerpColor(uint32_t c1, uint32_t c2, float ratio) {
if (ratio <= 0.0) return c1;
if (ratio >= 1.0) return c2;
uint8_t r1 = (c1 >> 16) & 0xFF;
uint8_t g1 = (c1 >> 8) & 0xFF;
uint8_t b1 = c1 & 0xFF;
uint8_t r2 = (c2 >> 16) & 0xFF;
uint8_t g2 = (c2 >> 8) & 0xFF;
uint8_t b2 = c2 & 0xFF;
// Interpolate each component
uint8_t r = r1 + (uint8_t)((r2 - r1) * ratio);
uint8_t g = g1 + (uint8_t)((g2 - g1) * ratio);
uint8_t b = b1 + (uint8_t)((b2 - b1) * ratio);
return pixels.Color(r, g, b);
}
/**
* @brief Converts a 6-digit hexadecimal string (RRGGBB) to a NeoPixel 32-bit color value.
*/
uint32_t hexToColor(String hexString) {
if (hexString.length() != 6) return 0;
long r = strtol(hexString.substring(0, 2).c_str(), NULL, 16);
long g = strtol(hexString.substring(2, 4).c_str(), NULL, 16);
long b = strtol(hexString.substring(4, 6).c_str(), NULL, 16);
return pixels.Color((uint8_t)r, (uint8_t)g, (uint8_t)b);
}
/**
* @brief Converts an (x, y) coordinate on the 64x8 matrix to a linear strip index (0-511).
*/
int xy_to_strip_index(int x, int y) {
int panelX = x / MATRIX_HEIGHT;
int panelBaseIndex = panelX * PANEL_SIZE;
int localX = x % MATRIX_HEIGHT;
int localY = y;
// Invert the localY (row index) to account for the panels being physically upside down
int invertedLocalY = (MATRIX_HEIGHT - 1) - localY;
// Simple Grid / Raster Scan map: localPixelIndex = (Inverted Row * 8) + Column
int localPixelIndex = (invertedLocalY * MATRIX_HEIGHT) + localX;
return panelBaseIndex + localPixelIndex;
}
/**
* @brief Draws a single 5x7 character at a specified starting column (X).
* Implements a **Vertical Flip** of the text and writes to the TargetDisplay array.
*/
void drawChar(char charCode, int startX, uint32_t color) {
int fontIndex = (int)charCode - 32;
if (fontIndex < 0 || fontIndex >= (sizeof(font5x7) / sizeof(font5x7[0]))) { fontIndex = 0; }
const int FONT_HEIGHT = 7;
const int FONT_WIDTH = 5;
const int verticalOffset = (MATRIX_HEIGHT - FONT_HEIGHT) / 2;
for (int fontRow = 0; fontRow < FONT_HEIGHT; fontRow++) {
for (int col = 0; col < FONT_WIDTH; col++) {
uint8_t columnData = pgm_read_byte(&(font5x7[fontIndex][col]));
if (columnData & (1 << fontRow)) {
int logical_x = startX + col;
int flipped_font_row = (FONT_HEIGHT - 1) - fontRow;
int logical_y = flipped_font_row + verticalOffset;
if (logical_x >= MATRIX_WIDTH || logical_y < 0 || logical_y >= MATRIX_HEIGHT) { continue; }
int finalPixelIndex = xy_to_strip_index(logical_x, logical_y);
// CRITICAL CHANGE: Set TargetDisplay array, NOT pixels buffer
if (finalPixelIndex < LED_COUNT) {
TargetDisplay[finalPixelIndex] = color;
}
}
}
}
}
/**
* @brief Parses the custom color tags and draws the text into the TargetDisplay array.
*/
void drawMessageToTarget(String message) {
// 1. Clear the TargetDisplay array (set all colors to 0 - black)
memset(TargetDisplay, 0, sizeof(TargetDisplay));
uint32_t currentColor = hexToColor("FF0000"); // Default to red
int currentPixelX = 0;
const int CHAR_SPACING = 1;
const int PIXEL_PER_CHAR = 5 + CHAR_SPACING;
const int MAX_DISPLAY_CHARS = MATRIX_WIDTH / PIXEL_PER_CHAR;
for (int i = 0; i < message.length(); i++) {
// Check for color tag start: "<#"
if (message.charAt(i) == '<' && message.substring(i, i + 2) == "<#") {
// Check for a complete color tag: "<#RRGGBB>" (length 9)
if (message.length() >= i + 9 && message.charAt(i + 8) == '>') {
String hex = message.substring(i + 3, i + 9);
currentColor = hexToColor(hex);
i += 8;
continue;
}
}
// --- Process and Display visible Character ---
char c = message.charAt(i);
c = toupper(c);
if (currentPixelX < MAX_DISPLAY_CHARS) {
int startX = currentPixelX * PIXEL_PER_CHAR;
drawChar(c, startX, currentColor);
currentPixelX++;
}
}
}
/**
* @brief Centralized function to manage all display activation, interruption, and fading.
* @param message The text string (including color tags) to fade to. Use BLANK_MESSAGE for fade-to-blank.
*/
void handleDisplay(String message) {
unsigned long pressTime = millis();
// --- 1. HANDLE SAME MESSAGE INTERRUPT (Force Fade-Out) ---
bool sameMessageInterrupt = (displayState != STATE_IDLE && currentMessage == message && message != BLANK_MESSAGE);
if (sameMessageInterrupt) {
Serial.println("Same message interrupt: Forcing fade-out.");
// If already transitioning, no change needed. If holding, initiate fade-out.
if (displayState != STATE_TRANSITION) {
// Set Source = Target (current held display) and Target = Blank
memcpy(SourceDisplay, TargetDisplay, sizeof(TargetDisplay));
memset(TargetDisplay, 0, sizeof(TargetDisplay));
transitionStartTime = pressTime;
displayState = STATE_TRANSITION;
}
// If already transitioning, it's fading to the same message, so let the current fade-out finish.
}
// --- 2. HANDLE NEW MESSAGE (Interrupt or Idle Start) ---
else if (message != BLANK_MESSAGE) {
Serial.print("New message: Starting transition to: "); Serial.println(message);
// A. Set Source Array: Use the current state of the pixels buffer.
// This ensures a smooth interrupt from any point (IDLE, HOLD, or mid-transition).
for (int i = 0; i < LED_COUNT; i++) {
SourceDisplay[i] = pixels.getPixelColor(i);
}
// B. Set Target Array: Draw the new message into the Target buffer.
drawMessageToTarget(message);
// C. Set state for Transition
currentMessage = message;
transitionStartTime = pressTime;
displayState = STATE_TRANSITION;
}
// --- 3. HANDLE TIMER EXPIRY (Fade to Blank) ---
else if (message == BLANK_MESSAGE) {
// Only trigger fade-out if currently holding (not already fading)
if (displayState == STATE_HOLD) {
Serial.println("Timer expired: Initiating fade-out to blank.");
// Set Source = Target (current held display) and Target = Blank
memcpy(SourceDisplay, TargetDisplay, sizeof(TargetDisplay));
memset(TargetDisplay, 0, sizeof(TargetDisplay));
transitionStartTime = pressTime;
displayState = STATE_TRANSITION;
}
}
}
// --- 4. Arduino Setup and Loop ---
void setup() {
Serial.begin(115200);
// Initialize NeoPixels
pixels.begin();
pixels.clear();
// CRITICAL: Set brightness to the max level. The LERP function handles the scaling
// from 0x000000 (black) to the target color (full intensity) to prevent flash.
pixels.setBrightness(BRIGHTNESS_LEVEL);
pixels.show();
// Initialize buffers to 0 (black)
memset(TargetDisplay, 0, sizeof(TargetDisplay));
memset(SourceDisplay, 0, sizeof(SourceDisplay));
// Initialize buttons using Bounce2 library (Input is INPUT_PULLUP)
for (int i = 0; i < NUM_BUTTONS; i++) {
buttons[i].attach(BUTTON_PINS[i], INPUT_PULLUP);
buttons[i].interval(DEBOUNCE_DELAY_MS);
}
// Display a startup message using the new handleDisplay function
handleDisplay(READY_MESSAGE);
}
void loop() {
unsigned long currentTime = millis();
// --- 1. LERP TRANSITIONING STATE ---
if (displayState == STATE_TRANSITION) {
unsigned long elapsed = currentTime - transitionStartTime;
float ratio = (float)elapsed / FADE_DURATION_MS;
if (ratio < 1.0) {
// Actively LERPing (Current Display)
for (int i = 0; i < LED_COUNT; i++) {
// Lerp between the starting state (Source) and the final state (Target)
uint32_t currentColor = lerpColor(SourceDisplay[i], TargetDisplay[i], ratio);
pixels.setPixelColor(i, currentColor);
}
pixels.show();
} else {
// Transition complete (ratio >= 1.0)
// Ensure the final state (TargetDisplay) is fully rendered
for (int i = 0; i < LED_COUNT; i++) {
pixels.setPixelColor(i, TargetDisplay[i]);
}
pixels.show();
// Check if the target was BLANK (0x000000)
bool isBlank = true;
for(int i = 0; i < LED_COUNT; i++) {
if(TargetDisplay[i] != 0) { isBlank = false; break; }
}
if (isBlank) {
// Faded to blank
displayState = STATE_IDLE;
currentMessage = BLANK_MESSAGE;
} else {
// Faded to a message, now hold
displayStartTime = currentTime;
displayState = STATE_HOLD;
}
}
}
// --- 2. HOLDING STATE ---
else if (displayState == STATE_HOLD) {
// Check if the hold time has expired
unsigned long holdDuration = currentTime - displayStartTime;
// Determine required hold time (READY or Button message)
unsigned long requiredHoldTime = (currentMessage == READY_MESSAGE) ? READY_HOLD_MS : DISPLAY_DURATION_MS;
if (holdDuration >= requiredHoldTime) {
// Duration expired, send signal to fade out to blank
handleDisplay(BLANK_MESSAGE);
}
}
// --- 3. BUTTON CHECK LOGIC ---
// Always check buttons to handle interruption
for (int i = 0; i < NUM_BUTTONS; i++) {
buttons[i].update();
if (buttons[i].fell()) {
// 1. Determine the message for the pressed button
String pressedMessage = "";
switch (i) {
case 0: pressedMessage = MESSAGE_1; break;
case 1: pressedMessage = MESSAGE_2; break;
case 2: pressedMessage = MESSAGE_3; break;
case 3: pressedMessage = MESSAGE_4; break;
case 4: pressedMessage = MESSAGE_5; break;
case 5: pressedMessage = MESSAGE_6; break;
}
// 2. Pass the message to the centralized handler
handleDisplay(pressedMessage);
}
}
}