// CREATED AND DESIGNED BY: MARVIN A. QUIZZAGAN - LCD DISPLAY WITH 4x4 switch matrix
// FEBRUARY 14, 2026
// COMPLETE CODE (SAFE SCROLL BUILDERS - NO BUFFER OVERFLOW)
// IMPORTANT NOTE: PIN D13 CANNOT BE USED FOR 4X4 MATRIX KEYPAD DUE TO IT IS USE WITH RESISTOR
// FOR LED INDICATOR ON BOARD, WHICH WILL AFFECT THE KEYPAD SIGNAL SCANNING
//
// NANO (2KB SRAM) STABILITY PATCH (FINAL):
// ✅ Removed favListStr[160] (SRAM hog)
// ✅ Favorites list scroll is now VIRTUAL (no giant concatenated string)
// ✅ Uses one global scroll buffer (prevents stack spikes in loop)
// ✅ Uses snprintf_P + PSTR for scroll strings (saves SRAM)
// ✅ Many lcd.print literals wrapped with F()
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <Keypad.h>
#include <EEPROM.h>
#include <stdio.h>
#include <string.h>
#include <avr/pgmspace.h>
// ---------------- LCD ----------------
LiquidCrystal_I2C lcd(0x27, 16, 2);
// -------------- Keypad 4x4 ----------
const byte ROWS = 4;
const byte COLS = 4;
char keys[ROWS][COLS] = {
{'1','2','3','A'},
{'4','5','6','B'},
{'7','8','9','C'},
{'*','0','#','D'}
};
byte rowPins[ROWS] = {4, 3, 2, A3};
byte colPins[COLS] = {A2, A1, A0, 12};
Keypad keypad = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
// ----------- 8-bit Output Pins -------
// D5 = bit7 ... D12 = bit0 (INVERTED LOGIC)
const byte outPins[8] = {5, 6, 7, 8, 9, 10, 11, 13};
// ----------- D0 INPUT (trigger) -------
const byte TRIG_PIN = 0; // D0 (RX)
// Robust debounced stable edge detector
bool trigRawPrev = HIGH;
bool trigStable = HIGH;
bool trigStablePrev = HIGH;
unsigned long trigLastChangeMs = 0;
const unsigned long TRIG_DEBOUNCE_MS = 30; // tunable
// ----------- D1 PASSIVE SPEAKER OUTPUT ------
const byte KEYPULSE_PIN = 1; // D1 (TX) (used for passive speaker)
const unsigned long KEYPULSE_MS = 35; // key click duration (tunable)
unsigned long keypulseUntilMs = 0; // kept for compatibility (not needed with tone duration)
// Key click frequency (tunable)
unsigned int KEYCLICK_HZ = 2600; // key press "tick" (tunable)
// -------- OUTPUT PULSE TIMER ----------
const unsigned long OUTPUT_PULSE_MS = 2000;
unsigned long pulseUntilMs = 0;
// -------- LONG PRESS TUNABLES ----------
unsigned long HASH_HOLD_MS = 5000; // long press #
unsigned long A_HOLD_MS = 5000; // long press A
unsigned long B_HOLD_MS = 2000; // long press B
unsigned long C_HOLD_MS = 2000; // long press C (favorites list edit)
unsigned long D_HOLD_MS = 2500; // long press D
unsigned long STAR_HOLD_MS = 5000; // long press *
// -------- ALERT SCREEN (generic) ------
unsigned long ALERT_SHOW_MS_DEFAULT = 5000;
unsigned long ALERT_BLINK_MS_DEFAULT = 400;
bool alertActive = false;
unsigned long alertUntilMs = 0;
unsigned long lastAlertBlinkMs = 0;
bool alertBlinkState = true;
unsigned long alertBlinkMs = 400;
char alertLine1[17];
char alertLine2[17];
// -------- CURSOR BLINK (typing UI) ----
bool cursorBlinkState = true;
unsigned long lastCursorBlinkMs = 0;
const unsigned long CURSOR_BLINK_MS = 500;
// -------- LINE 1 BLINK (NORMAL SCREEN: 0-255 and #) ----
bool line1BlinkState = true;
unsigned long lastLine1BlinkMs = 0;
unsigned long LINE1_BLINK_MS = 500; // tunable
bool LINE1_BLINK_ENABLE = true;
// -------- FAVORITES MODE BLINK ----
bool favBlinkState = true;
unsigned long lastFavBlinkMs = 0;
unsigned long FAV_BLINK_MS = 600;
bool favCursorBlinkState = true;
unsigned long lastFavCursorBlinkMs = 0;
// ✅ only "(B)" blink (SAVE stable)
unsigned long FAV_B_BLINK_MS = 450;
bool favBBlinkState = true;
unsigned long lastFavBBlinkMs = 0;
// ------------- Input buffer -----------
char buf[4] = {'\0','\0','\0','\0'};
byte bufLen = 0;
bool entryDirty = false;
// ----------------- UI timing ----------
const unsigned long TYPING_HOLD_MS = 5000;
const unsigned long APPLIED_SHOW_MS = 1000;
const unsigned long ERROR_SHOW_MS = 3000;
const unsigned long SCROLL_STEP_MS = 250;
const unsigned long ERROR_SCROLL_STEP_MS = 220;
unsigned long typingUntilMs = 0;
unsigned long appliedUntilMs = 0;
unsigned long errorUntilMs = 0;
unsigned long lastScrollMs = 0;
int scrollPos = 0;
int errorScrollPos = 0;
// Playing latch
uint16_t playingValue = 0;
uint16_t lastPlayedValue = 0;
uint16_t lastErrorValue = 0;
// After LONG * => forget previous number behavior
bool forgetPrev = false;
// ----------- Modes -----------
enum Mode : byte {
MODE_NORMAL,
MODE_REPEAT,
MODE_STEP,
MODE_FAV_EDIT,
MODE_FAV_PLAY,
MODE_FAV_ERASE_AUTH,
MODE_FAV_LIST_EDIT_AUTH,
MODE_FAV_LIST_EDIT,
MODE_FAV_ITEM_EDIT
};
Mode mode = MODE_NORMAL;
// STEP mode state
uint16_t stepCurrent = 0;
// -------- Long-press tracking --------
bool hashDown = false; unsigned long hashDownStartMs = 0; bool hashLongFired = false;
bool aDown = false; unsigned long aDownStartMs = 0; bool aLongFired = false;
bool bDown = false; unsigned long bDownStartMs = 0; bool bLongFired = false;
bool cDown = false; unsigned long cDownStartMs = 0; bool cLongFired = false;
bool dDown = false; unsigned long dDownStartMs = 0; bool dLongFired = false;
bool starDown = false; unsigned long starDownStartMs = 0; bool starLongFired = false;
// -------------------- FAVORITES DATA --------------------
const byte MAX_FAV = 10;
uint16_t favList[MAX_FAV];
byte favCount = 0;
byte favIndex = 0;
unsigned long favMsgUntilMs = 0;
char favLastLine2[17] = "________________";
bool favTypingView = false;
const byte FAV_SAVE_COL = 16 - (sizeof("SAVE(B)") - 1); // 16 - 7 = 9
// -------------------- EEPROM PERSISTENCE --------------------
const int EEPROM_ADDR = 0;
const uint16_t EEPROM_MAGIC = 0x4D51; // 'M''Q'
const uint8_t EEPROM_VER = 1;
struct FavEepromBlob {
uint16_t magic;
uint8_t ver;
uint8_t count;
uint16_t list[MAX_FAV];
uint16_t checksum;
};
// -------------------- PASSWORD --------------------
const char PW_CODE[] = "161979";
const byte PW_LEN = 6;
char pwBuf[PW_LEN + 1];
byte pwLen = 0;
unsigned long pwWrongFlag = 0; // 0=none, 1=return to D auth, 2=return to C auth
// ---- PASSWORD ENTRY TIMEOUT ----
const unsigned long PW_IDLE_TIMEOUT_MS = 5000;
unsigned long pwLastInputMs = 0;
// ---- RANDOM MASK DISPLAY (anti-shoulder-surf) ----
char pwMask[PW_LEN + 1];
// ---- SMOOTH CURSOR BLINK (D ERASE AUTH) ----
bool pwCursorBlinkState = true;
unsigned long lastPwCursorBlinkMs = 0;
const unsigned long PW_CURSOR_BLINK_MS = 450;
// ---- SMOOTH CURSOR BLINK (C EDIT AUTH) ----
bool pwEditCursorBlinkState = true;
unsigned long lastPwEditCursorBlinkMs = 0;
const unsigned long PW_EDIT_CURSOR_BLINK_MS = PW_CURSOR_BLINK_MS;
// -------------------- LIST EDIT STATE --------------------
byte editSelIndex = 0;
int editScrollPos = 0;
unsigned long lastEditScrollMs = 0;
char editWin[17];
// =========================================================
// BACKLIGHT + DISPLAY SLEEP (TUNABLE)
// =========================================================
unsigned long LCD_IDLE_BEFORE_DIM_MS = 120000;
unsigned long LCD_DIM_DURATION_MS = 3000;
const unsigned long DIM_PWM_PERIOD_MS = 5;
unsigned long lastDimPwmMs = 0;
byte dimPwmPhase = 0;
unsigned long lastKeyTouchMs = 0;
bool dimmingActive = false;
unsigned long dimStartMs = 0;
bool displaySleeping = false;
// ✅ Freeze LCD writes during dim/sleep so wake shows EXACT previous screen.
bool lcdWritesFrozen = false;
// =========================================================
// WRONG PASSWORD ALERT + PASSIVE SIREN ON D1 (TUNABLE)
// =========================================================
unsigned long PW_WRONG_TOTAL_MS = 1800; // synced display+sound duration
unsigned long PW_WRONG_FLASH_MS = 220; // flash speed
unsigned int PW_SIREN_MIN_HZ = 650;
unsigned int PW_SIREN_MAX_HZ = 1800;
unsigned int PW_SIREN_STEP_HZ = 60;
unsigned long PW_SIREN_ON_MS = 90;
unsigned long PW_SIREN_OFF_MS = 70;
bool PW_SIREN_ENABLE = true;
bool alarmToneActive = false;
unsigned long alarmUntilMs = 0;
unsigned long alarmNextToggleMs = 0;
bool alarmOn = false;
int alarmDir = +1;
unsigned int alarmFreqHz = 900;
// =========================================================
// SUCCESS TONE (PASSIVE) - TUNABLE
// =========================================================
bool SUCCESS_TONE_ENABLE = true;
unsigned int SUCCESS_TONE_HZ_1 = 1200;
unsigned int SUCCESS_TONE_HZ_2 = 1800;
unsigned int SUCCESS_TONE_HZ_3 = 2400;
unsigned long SUCCESS_STEP_MS = 70;
unsigned long SUCCESS_GAP_MS = 25;
bool successToneActive = false;
byte successToneStep = 0;
unsigned long successNextMs = 0;
// =========================================================
// NANO SRAM PATCH: ONE GLOBAL SCROLL BUFFER (NO STACK SPIKES)
// =========================================================
char scrollBuf[96]; // shared buffer for all scrolling messages
// -------------------- Forward Decls --------------------
void resetVisualTimers();
void enterFavListEditAuthMode();
void enterEraseAuthMode();
void favEnterEditMode();
void enterFavListEditMode();
// =========================================================
// EEPROM
// =========================================================
uint16_t checksumBlob(const FavEepromBlob &b) {
uint32_t sum = 0;
sum += b.magic;
sum += b.ver;
sum += b.count;
for (byte i = 0; i < MAX_FAV; i++) sum += b.list[i];
return (uint16_t)(sum & 0xFFFF);
}
void saveFavoritesToEEPROM() {
FavEepromBlob b;
b.magic = EEPROM_MAGIC;
b.ver = EEPROM_VER;
b.count = favCount;
for (byte i = 0; i < MAX_FAV; i++) b.list[i] = (i < favCount) ? favList[i] : 0;
b.checksum = checksumBlob(b);
EEPROM.put(EEPROM_ADDR, b);
}
void loadFavoritesFromEEPROM() {
FavEepromBlob b;
EEPROM.get(EEPROM_ADDR, b);
if (b.magic != EEPROM_MAGIC || b.ver != EEPROM_VER) { favCount = 0; return; }
if (b.checksum != checksumBlob(b)) { favCount = 0; return; }
if (b.count > MAX_FAV) { favCount = 0; return; }
favCount = b.count;
for (byte i = 0; i < favCount; i++) {
uint16_t v = b.list[i];
if (v > 255) v = 0;
favList[i] = v;
}
}
// -------------------- Helpers --------------------
void setOutputsFromValue(uint16_t value) {
for (byte i = 0; i < 8; i++) {
byte bitVal = (value >> (7 - i)) & 0x01;
digitalWrite(outPins[i], bitVal ? LOW : HIGH);
}
}
uint16_t bufferToNumber() { return bufLen ? (uint16_t)atoi(buf) : 0; }
void clearBuffer() {
bufLen = 0;
buf[0] = buf[1] = buf[2] = '\0';
buf[3] = '\0';
entryDirty = false;
}
void appendDigit(char d) {
forgetPrev = false;
if (bufLen < 3) {
buf[bufLen++] = d;
buf[bufLen] = '\0';
} else {
buf[0] = buf[1];
buf[1] = buf[2];
buf[2] = d;
buf[3] = '\0';
bufLen = 3;
}
entryDirty = true;
}
void backspaceDigit() {
if (bufLen == 0) return;
bufLen--;
buf[bufLen] = '\0';
entryDirty = (bufLen > 0);
}
// -------------------- RANDOM MASK HELPERS --------------------
char randomDigitNot(char avoidChar) {
byte avoid = (byte)(avoidChar - '0');
byte d;
do { d = (byte)random(0, 10); } while (d == avoid);
return (char)('0' + d);
}
void regenPwMaskAll() {
for (byte i = 0; i < pwLen && i < PW_LEN; i++) {
pwMask[i] = randomDigitNot(pwBuf[i]);
}
pwMask[pwLen] = '\0';
}
// -------------------- LCD write guards --------------------
inline bool lcdCanWrite() { return !lcdWritesFrozen; }
// -------------------- LCD helpers --------------------
void clearLine2() {
if (!lcdCanWrite()) return;
lcd.setCursor(0, 1);
lcd.print(F(" "));
}
void initLine1() {
if (!lcdCanWrite()) return;
lcd.setCursor(0, 0); lcd.print(F("SEL:"));
lcd.setCursor(9, 0); lcd.print(F(",THEN "));
lcd.setCursor(4, 0); lcd.print(F("0-255"));
lcd.setCursor(15, 0); lcd.print(F("#"));
}
void showMp3PlayModeLine1() {
if (!lcdCanWrite()) return;
lcd.setCursor(0, 0);
lcd.print(F("MP3 PLAY MODE: "));
}
void updateLine1Blink() {
if (!lcdCanWrite()) return;
if (!LINE1_BLINK_ENABLE) {
lcd.setCursor(4, 0); lcd.print(F("0-255"));
lcd.setCursor(15, 0); lcd.print(F("#"));
return;
}
if (millis() - lastLine1BlinkMs >= LINE1_BLINK_MS) {
lastLine1BlinkMs = millis();
line1BlinkState = !line1BlinkState;
if (line1BlinkState) {
lcd.setCursor(4, 0); lcd.print(F("0-255"));
lcd.setCursor(15, 0); lcd.print(F("#"));
} else {
lcd.setCursor(4, 0); lcd.print(F(" "));
lcd.setCursor(15, 0); lcd.print(F(" "));
}
}
}
void showTypedDigitsLine2() {
if (!lcdCanWrite()) return;
if (millis() - lastCursorBlinkMs >= CURSOR_BLINK_MS) {
lastCursorBlinkMs = millis();
cursorBlinkState = !cursorBlinkState;
}
lcd.setCursor(0, 1);
lcd.print(F("eMAR TO PLAY:"));
char d0 = '_', d1 = '_', d2 = '_';
if (bufLen == 1) d2 = buf[0];
else if (bufLen == 2) { d1 = buf[0]; d2 = buf[1]; }
else if (bufLen >= 3) { d0 = buf[bufLen - 3]; d1 = buf[bufLen - 2]; d2 = buf[bufLen - 1]; }
if (!cursorBlinkState && bufLen < 3) {
if (bufLen == 0) d2 = ' ';
if (bufLen == 1) d1 = ' ';
if (bufLen == 2) d0 = ' ';
}
lcd.setCursor(13, 1);
lcd.print(d0); lcd.print(d1); lcd.print(d2);
}
void showAppliedLine2(uint16_t value) {
if (!lcdCanWrite()) return;
clearLine2();
lcd.setCursor(0, 1);
lcd.print(F("APPLIED "));
lcd.print(value);
int used = 8 + (value < 10 ? 1 : (value < 100 ? 2 : 3));
for (int i = used; i < 16; i++) lcd.print(' ');
}
// -------------------- SAFE Scroll builders (PROGMEM) --------------------
void buildNormalScrollText(char *out, size_t outSize, uint16_t value) {
if (value == 0) snprintf_P(out, outSize, PSTR("E-MAR IS READY TO PLAY NOW "));
else snprintf_P(out, outSize, PSTR("PLAYING %u "), (unsigned)value);
}
void buildRepeatScrollText(char *out, size_t outSize, uint16_t value) {
if (value == 0) snprintf_P(out, outSize, PSTR("E-MAR IS READY TO PLAY NOW "));
else snprintf_P(out, outSize, PSTR("PLAYING %u REPEATEDLY "), (unsigned)value);
}
void buildStepScrollText(char *out, size_t outSize, uint16_t value) {
snprintf_P(out, outSize, PSTR("STEP PLAY MODE: PLAYING %u "), (unsigned)value);
}
void buildErrorScrollText(char *out, size_t outSize, uint16_t value) {
snprintf_P(out, outSize, PSTR("ERROR %u OUT OF RANGE "), (unsigned)value);
}
void buildFavNowText(char *out, size_t outSize, byte idx, byte total, uint16_t v) {
snprintf_P(out, outSize, PSTR("FAV %u/%u PLAY %u "),
(unsigned)(idx + 1), (unsigned)total, (unsigned)v);
}
void showScrollWindow(const char *text, int &pos) {
if (!lcdCanWrite()) return;
int len = (int)strlen(text);
if (len <= 0) return;
if (pos >= len) pos = 0;
char win[17];
for (byte i = 0; i < 16; i++) win[i] = text[(pos + i) % len];
win[16] = '\0';
lcd.setCursor(0, 1);
lcd.print(win);
}
// -------------------- Visual reset --------------------
void resetVisualTimers() {
typingUntilMs = 0;
appliedUntilMs = 0;
errorUntilMs = 0;
favMsgUntilMs = 0;
scrollPos = 0;
errorScrollPos = 0;
lastScrollMs = millis();
clearLine2();
}
// -------------------- ALERT --------------------
void startAlertScreen(const char *l1, const char *l2, unsigned long showMs, unsigned long blinkMs) {
alertActive = true;
alertUntilMs = millis() + showMs;
lastAlertBlinkMs = 0;
alertBlinkState = true;
alertBlinkMs = blinkMs;
snprintf(alertLine1, sizeof(alertLine1), "%-16.16s", l1);
snprintf(alertLine2, sizeof(alertLine2), "%-16.16s", l2);
if (!lcdCanWrite()) return;
lcd.setCursor(0, 0); lcd.print(alertLine1);
lcd.setCursor(0, 1); lcd.print(alertLine2);
}
void updateAlertScreen() {
if (!alertActive) return;
if (millis() >= alertUntilMs) {
alertActive = false;
return;
}
if (!lcdCanWrite()) return;
if (millis() - lastAlertBlinkMs >= alertBlinkMs) {
lastAlertBlinkMs = millis();
alertBlinkState = !alertBlinkState;
if (alertBlinkState) {
lcd.setCursor(0, 0); lcd.print(alertLine1);
lcd.setCursor(0, 1); lcd.print(alertLine2);
} else {
lcd.setCursor(0, 0); lcd.print(F(" "));
lcd.setCursor(0, 1); lcd.print(F(" "));
}
}
}
// =========================================================
// PASSIVE SIREN ALARM ON D1 (NON-BLOCKING) - WRONG PASSWORD
// =========================================================
void stopAlarmTone() {
alarmToneActive = false;
alarmOn = false;
noTone(KEYPULSE_PIN);
}
void startAlarmTone(unsigned long totalMs) {
if (!PW_SIREN_ENABLE) return;
// stop any success tone step
successToneActive = false;
noTone(KEYPULSE_PIN);
alarmToneActive = true;
alarmUntilMs = millis() + totalMs;
alarmDir = +1;
alarmFreqHz = PW_SIREN_MIN_HZ;
alarmOn = true;
tone(KEYPULSE_PIN, alarmFreqHz);
alarmNextToggleMs = millis() + PW_SIREN_ON_MS;
}
void updateAlarmTone() {
if (!alarmToneActive) return;
unsigned long now = millis();
if (now >= alarmUntilMs) {
stopAlarmTone();
return;
}
if (now < alarmNextToggleMs) return;
if (alarmOn) {
noTone(KEYPULSE_PIN);
alarmOn = false;
alarmNextToggleMs = now + PW_SIREN_OFF_MS;
} else {
int next = (int)alarmFreqHz + alarmDir * (int)PW_SIREN_STEP_HZ;
if (next >= (int)PW_SIREN_MAX_HZ) { next = PW_SIREN_MAX_HZ; alarmDir = -1; }
if (next <= (int)PW_SIREN_MIN_HZ) { next = PW_SIREN_MIN_HZ; alarmDir = +1; }
alarmFreqHz = (unsigned int)next;
tone(KEYPULSE_PIN, alarmFreqHz);
alarmOn = true;
alarmNextToggleMs = now + PW_SIREN_ON_MS;
}
}
// =========================================================
// SUCCESS TONE (NON-BLOCKING)
// =========================================================
void startSuccessTone() {
if (!SUCCESS_TONE_ENABLE) return;
stopAlarmTone();
noTone(KEYPULSE_PIN);
successToneActive = true;
successToneStep = 0;
successNextMs = 0;
}
void updateSuccessTone() {
if (!successToneActive) return;
unsigned long now = millis();
if (successNextMs && now < successNextMs) return;
switch (successToneStep) {
case 0:
tone(KEYPULSE_PIN, SUCCESS_TONE_HZ_1);
successNextMs = now + SUCCESS_STEP_MS;
successToneStep = 1;
break;
case 1:
noTone(KEYPULSE_PIN);
successNextMs = now + SUCCESS_GAP_MS;
successToneStep = 2;
break;
case 2:
tone(KEYPULSE_PIN, SUCCESS_TONE_HZ_2);
successNextMs = now + SUCCESS_STEP_MS;
successToneStep = 3;
break;
case 3:
noTone(KEYPULSE_PIN);
successNextMs = now + SUCCESS_GAP_MS;
successToneStep = 4;
break;
case 4:
tone(KEYPULSE_PIN, SUCCESS_TONE_HZ_3);
successNextMs = now + SUCCESS_STEP_MS;
successToneStep = 5;
break;
default:
noTone(KEYPULSE_PIN);
successToneActive = false;
successToneStep = 0;
successNextMs = 0;
break;
}
}
// WRONG CODE alert wrapper: sync display + siren duration
void startWrongCodeAlert(byte returnFlag) {
pwWrongFlag = returnFlag;
startAlertScreen("WRONG CODE", "TRY AGAIN", PW_WRONG_TOTAL_MS, PW_WRONG_FLASH_MS);
startAlarmTone(PW_WRONG_TOTAL_MS);
}
// -------------------- D1 key click --------------------
void triggerKeyPulse() {
if (alarmToneActive) return;
if (successToneActive) return;
tone(KEYPULSE_PIN, KEYCLICK_HZ, (unsigned int)KEYPULSE_MS);
}
// =========================================================
// NANO SRAM PATCH: VIRTUAL FAVORITES LIST (NO favListStr[160])
// =========================================================
int favVirtualLen() {
if (favCount == 0) return 20; // "EMPTY " length-ish
int len = 0;
for (byte i = 0; i < favCount; i++) {
// "(%u)%03u" + optional ", "
// index 1..10 => 1-2 digits, plus '(' and ')'
byte idx = i + 1;
len += 2; // '(' + ')'
len += (idx < 10 ? 1 : 2);// index digits
len += 3; // value 000-255
if (i != favCount - 1) len += 2; // ", "
}
len += 16; // trailing spaces for smooth wrap
return len;
}
char favCharAt(int p) {
if (favCount == 0) {
static const char emptyMsg[] = "EMPTY ";
return emptyMsg[p % (int)(sizeof(emptyMsg) - 1)];
}
for (byte i = 0; i < favCount; i++) {
char seg[10]; // "(10)255" + NUL fits
byte idx = i + 1;
uint16_t v = favList[i];
snprintf(seg, sizeof(seg), "(%u)%03u", idx, (unsigned)v);
int segLen = (int)strlen(seg);
int totalSeg = segLen + ((i != favCount - 1) ? 2 : 0);
if (p < totalSeg) {
if (p < segLen) return seg[p];
return (p == segLen) ? ',' : ' ';
}
p -= totalSeg;
}
return ' ';
}
static void printPad16(const char *s) {
if (!lcdCanWrite()) return;
lcd.print(s);
int n = (int)strlen(s);
for (int i = n; i < 16; i++) lcd.print(' ');
}
// -------------------- RESET --------------------
void fullResetToNormal() {
mode = MODE_NORMAL;
pulseUntilMs = 0;
setOutputsFromValue(0);
clearBuffer();
playingValue = 0;
stepCurrent = 0;
favIndex = 0;
pwLen = 0;
pwBuf[0] = '\0';
pwMask[0] = '\0';
pwWrongFlag = 0;
pwLastInputMs = 0;
pwCursorBlinkState = true;
lastPwCursorBlinkMs = millis();
pwEditCursorBlinkState = true;
lastPwEditCursorBlinkMs = millis();
editSelIndex = 0;
editScrollPos = 0;
hashDown = false; hashLongFired = false;
aDown = false; aLongFired = false;
bDown = false; bLongFired = false;
cDown = false; cLongFired = false;
dDown = false; dLongFired = false;
starDown = false; starLongFired = false;
// stop tones
stopAlarmTone();
successToneActive = false;
noTone(KEYPULSE_PIN);
initLine1();
resetVisualTimers();
}
// -------------------- LONG * behavior --------------------
void longStarForgetReset() {
forgetPrev = true;
lastPlayedValue = 0;
fullResetToNormal();
startAlertScreen("ATTENTION: ALL", "CLEARED & RESET",
ALERT_SHOW_MS_DEFAULT, ALERT_BLINK_MS_DEFAULT);
}
// -------------------- Play helpers --------------------
void startPulsePlay(uint16_t v) {
mode = MODE_NORMAL;
forgetPrev = false;
playingValue = v;
lastPlayedValue = v;
setOutputsFromValue(v);
pulseUntilMs = millis() + OUTPUT_PULSE_MS;
appliedUntilMs = millis() + APPLIED_SHOW_MS;
showAppliedLine2(v);
clearBuffer();
typingUntilMs = 0;
scrollPos = 0;
lastScrollMs = millis();
}
void enterRepeatMode(uint16_t v) {
if (mode == MODE_STEP) return;
mode = MODE_REPEAT;
pulseUntilMs = 0;
playingValue = v;
lastPlayedValue = v;
setOutputsFromValue(v);
clearBuffer();
typingUntilMs = 0;
appliedUntilMs = 0;
errorUntilMs = 0;
clearLine2();
scrollPos = 0;
lastScrollMs = millis();
}
void enterStepMode(uint16_t startVal) {
mode = MODE_STEP;
showMp3PlayModeLine1();
pulseUntilMs = 0;
setOutputsFromValue(0);
if (startVal > 255) startVal = 255;
stepCurrent = startVal;
playingValue = stepCurrent;
lastPlayedValue = stepCurrent;
setOutputsFromValue(stepCurrent);
pulseUntilMs = millis() + OUTPUT_PULSE_MS;
clearBuffer();
typingUntilMs = 0;
appliedUntilMs = millis() + APPLIED_SHOW_MS;
showAppliedLine2(stepCurrent);
clearLine2();
scrollPos = 0;
lastScrollMs = millis();
}
void stepAdvanceOnTrig() {
if (mode != MODE_STEP) return;
if (stepCurrent >= 255) {
fullResetToNormal();
return;
}
stepCurrent++;
playingValue = stepCurrent;
lastPlayedValue = stepCurrent;
setOutputsFromValue(stepCurrent);
pulseUntilMs = millis() + OUTPUT_PULSE_MS;
appliedUntilMs = millis() + APPLIED_SHOW_MS;
showAppliedLine2(stepCurrent);
scrollPos = 0;
lastScrollMs = millis();
}
// -------------------- FAVORITES helpers --------------------
bool favAdd(uint16_t v) {
if (v > 255) return false;
if (favCount >= MAX_FAV) return false;
favList[favCount++] = v;
saveFavoritesToEEPROM();
return true;
}
void favClearAll() {
favCount = 0;
favIndex = 0;
saveFavoritesToEEPROM();
}
void favDrawLine1Once() {
if (!lcdCanWrite()) return;
lcd.setCursor(0, 0);
lcd.print(F("FAVORITES MODE "));
}
void favDrawPromptOnce() {
favTypingView = false;
favDrawLine1Once();
if (!lcdCanWrite()) return;
lcd.setCursor(0, 1);
lcd.print(F("ENT:0-255,SAVE:B"));
lcd.setCursor(4, 1); lcd.print(F("0-255"));
lcd.setCursor(15, 1); lcd.print(F("B"));
strncpy(favLastLine2, "ENT:0-255,SAVE:B", 16);
favLastLine2[16] = '\0';
favCursorBlinkState = true;
lastFavCursorBlinkMs = millis();
favBBlinkState = true;
lastFavBBlinkMs = millis();
}
void favDrawTypingLine2Once() {
favTypingView = true;
if (!lcdCanWrite()) return;
char line[17];
for (byte i = 0; i < 16; i++) line[i] = ' ';
line[16] = '\0';
line[0] = 'F'; line[1] = 'A'; line[2] = 'V'; line[3] = ':';
line[4] = '_'; line[5] = '_'; line[6] = '_';
if (bufLen == 1) {
line[6] = buf[0];
} else if (bufLen == 2) {
line[5] = buf[0];
line[6] = buf[1];
} else if (bufLen >= 3) {
line[4] = buf[bufLen - 3];
line[5] = buf[bufLen - 2];
line[6] = buf[bufLen - 1];
}
line[FAV_SAVE_COL + 0] = 'S';
line[FAV_SAVE_COL + 1] = 'A';
line[FAV_SAVE_COL + 2] = 'V';
line[FAV_SAVE_COL + 3] = 'E';
line[FAV_SAVE_COL + 4] = '(';
line[FAV_SAVE_COL + 5] = 'B';
line[FAV_SAVE_COL + 6] = ')';
if (strncmp(line, favLastLine2, 16) != 0) {
favDrawLine1Once();
lcd.setCursor(0, 1);
lcd.print(line);
strncpy(favLastLine2, line, 16);
favLastLine2[16] = '\0';
favCursorBlinkState = true;
lastFavCursorBlinkMs = millis();
favBBlinkState = true;
lastFavBBlinkMs = millis();
}
}
void favBlinkPromptUpdate() {
if (!lcdCanWrite()) return;
if ((unsigned long)(millis() - lastFavBlinkMs) < FAV_BLINK_MS) return;
lastFavBlinkMs += FAV_BLINK_MS;
favBlinkState = !favBlinkState;
if (favBlinkState) {
lcd.setCursor(4, 1); lcd.print(F("0-255"));
lcd.setCursor(15, 1); lcd.print(F("B"));
} else {
lcd.setCursor(4, 1); lcd.print(F(" "));
lcd.setCursor(15, 1); lcd.print(F(" "));
}
}
void favBlinkTypingSaveUpdate() {
if (!lcdCanWrite()) return;
if ((unsigned long)(millis() - lastFavBBlinkMs) < FAV_B_BLINK_MS) return;
lastFavBBlinkMs += FAV_B_BLINK_MS;
favBBlinkState = !favBBlinkState;
lcd.setCursor(FAV_SAVE_COL, 1);
lcd.print(F("SAVE"));
lcd.setCursor(FAV_SAVE_COL + 4, 1);
if (favBBlinkState) lcd.print(F("(B)"));
else lcd.print(F(" "));
}
void favBlinkTypingCursorUpdate() {
if (!lcdCanWrite()) return;
if (bufLen >= 3) return;
if (millis() - lastFavCursorBlinkMs >= CURSOR_BLINK_MS) {
lastFavCursorBlinkMs = millis();
favCursorBlinkState = !favCursorBlinkState;
byte cursorCol = 6 - bufLen;
lcd.setCursor(cursorCol, 1);
lcd.print(favCursorBlinkState ? "_" : " ");
}
}
void favEnterEditMode() {
mode = MODE_FAV_EDIT;
clearBuffer();
favMsgUntilMs = 0;
favBlinkState = true;
lastFavBlinkMs = millis();
favCursorBlinkState = true;
lastFavCursorBlinkMs = millis();
favBBlinkState = true;
lastFavBBlinkMs = millis();
favDrawPromptOnce();
}
void favEnterPlayMode() {
if (favCount == 0) {
if (lcdCanWrite()) {
lcd.setCursor(0, 0); lcd.print(F("FAVORITES MODE "));
lcd.setCursor(0, 1); lcd.print(F("EMPTY - ADD NUM "));
}
favMsgUntilMs = millis() + 1500;
return;
}
mode = MODE_FAV_PLAY;
clearBuffer();
appliedUntilMs = 0;
errorUntilMs = 0;
typingUntilMs = 0;
favIndex = 0;
playingValue = favList[favIndex];
lastPlayedValue = playingValue;
if (lcdCanWrite()) {
lcd.setCursor(0, 0);
lcd.print(F("FAV PLAY D0/B "));
}
setOutputsFromValue(playingValue);
pulseUntilMs = millis() + OUTPUT_PULSE_MS;
scrollPos = 0;
lastScrollMs = millis();
}
void favAdvance() {
if (mode != MODE_FAV_PLAY) return;
if (favCount == 0) { fullResetToNormal(); return; }
if (favIndex >= (favCount - 1)) {
fullResetToNormal();
return;
}
favIndex++;
playingValue = favList[favIndex];
lastPlayedValue = playingValue;
setOutputsFromValue(playingValue);
pulseUntilMs = millis() + OUTPUT_PULSE_MS;
scrollPos = 0;
lastScrollMs = millis();
}
void favShowCountAndLast() {
favDrawLine1Once();
if (!lcdCanWrite()) return;
lcd.setCursor(0, 1);
lcd.print(F(" "));
char line[17];
if (favCount > 0) {
uint16_t last = favList[favCount - 1];
snprintf(line, sizeof(line), "CNT:%2u LAST:%03u ", (unsigned)favCount, (unsigned)last);
} else {
snprintf(line, sizeof(line), "CNT:%2u LAST:--- ", (unsigned)favCount);
}
lcd.setCursor(0, 1);
lcd.print(line);
favMsgUntilMs = millis() + 1300;
favBlinkState = true;
lastFavBlinkMs = millis();
favCursorBlinkState = true;
lastFavCursorBlinkMs = millis();
favBBlinkState = true;
lastFavBBlinkMs = millis();
}
// -------------------- D (erase favorites) AUTH MODE --------------------
void enterEraseAuthMode() {
mode = MODE_FAV_ERASE_AUTH;
pwLen = 0;
pwBuf[0] = '\0';
pwMask[0] = '\0';
pwWrongFlag = 0;
pwCursorBlinkState = true;
lastPwCursorBlinkMs = millis();
setOutputsFromValue(0);
pulseUntilMs = 0;
if (lcdCanWrite()) {
lcd.setCursor(0, 0); lcd.print(F("ENTER THE CODE: "));
lcd.setCursor(0, 1); lcd.print(F(" "));
lcd.setCursor(0, 1); lcd.print(F("_"));
}
pwLastInputMs = millis();
}
void showEraseMaskedQuiet() {
if (!lcdCanWrite()) return;
lcd.setCursor(0, 1);
lcd.print(F(" "));
lcd.setCursor(0, 1);
for (byte i = 0; i < pwLen && i < PW_LEN; i++) lcd.print(pwMask[i]);
byte col = pwLen;
if (col > 15) col = 15;
lcd.setCursor(col, 1);
lcd.print(F("_"));
pwCursorBlinkState = true;
lastPwCursorBlinkMs = millis();
}
void erasePwCursorUpdate() {
if (!lcdCanWrite()) return;
if (mode != MODE_FAV_ERASE_AUTH) return;
if (pwLen >= PW_LEN) return;
unsigned long now = millis();
if (now - lastPwCursorBlinkMs < PW_CURSOR_BLINK_MS) return;
lastPwCursorBlinkMs += PW_CURSOR_BLINK_MS;
pwCursorBlinkState = !pwCursorBlinkState;
byte col = pwLen;
if (col > 15) col = 15;
lcd.setCursor(col, 1);
lcd.print(pwCursorBlinkState ? "_" : " ");
}
void eraseAuthBackspace() {
if (pwLen == 0) return;
pwLen--;
pwBuf[pwLen] = '\0';
regenPwMaskAll();
showEraseMaskedQuiet();
}
void eraseAuthFailStart() { startWrongCodeAlert(1); }
void eraseAuthSuccess() {
startSuccessTone();
favClearAll();
startAlertScreen("THE FAVORITES:", "CLEARED & RESET",
ALERT_SHOW_MS_DEFAULT, ALERT_BLINK_MS_DEFAULT);
mode = MODE_NORMAL;
}
// -------------------- C (edit list) AUTH MODE --------------------
void showFavListEditMaskedQuiet(); // forward
void enterFavListEditAuthMode() {
mode = MODE_FAV_LIST_EDIT_AUTH;
pwLen = 0;
pwBuf[0] = '\0';
pwMask[0] = '\0';
pwWrongFlag = 0;
pwEditCursorBlinkState = true;
lastPwEditCursorBlinkMs = millis();
pwLastInputMs = millis();
showFavListEditMaskedQuiet();
}
void showFavListEditMaskedQuiet() {
if (!lcdCanWrite()) return;
lcd.setCursor(0, 0); lcd.print(F("EDIT FAVORITES: "));
lcd.setCursor(0, 1); lcd.print(F("ENTER PW: "));
lcd.setCursor(9, 1);
for (byte i = 0; i < pwLen && i < PW_LEN; i++) lcd.print(pwMask[i]);
for (byte i = pwLen; i < PW_LEN; i++) lcd.print(' ');
byte col = 9 + pwLen;
if (col > 15) col = 15;
lcd.setCursor(col, 1);
lcd.print(F("_"));
pwEditCursorBlinkState = true;
lastPwEditCursorBlinkMs = millis();
}
void favListPwCursorUpdate() {
if (!lcdCanWrite()) return;
if (mode != MODE_FAV_LIST_EDIT_AUTH) return;
if (pwLen >= PW_LEN) return;
unsigned long now = millis();
if (now - lastPwEditCursorBlinkMs < PW_EDIT_CURSOR_BLINK_MS) return;
lastPwEditCursorBlinkMs += PW_EDIT_CURSOR_BLINK_MS;
pwEditCursorBlinkState = !pwEditCursorBlinkState;
byte col = 9 + pwLen;
if (col > 15) col = 15;
lcd.setCursor(col, 1);
lcd.print(pwEditCursorBlinkState ? "_" : " ");
}
void favListAuthBackspace() {
if (pwLen == 0) return;
pwLen--;
pwBuf[pwLen] = '\0';
regenPwMaskAll();
showFavListEditMaskedQuiet();
}
void favListAuthFailStart() { startWrongCodeAlert(2); }
// -------------------- LIST EDIT MODE (VIRTUAL SCROLL) --------------------
void enterFavListEditMode() {
mode = MODE_FAV_LIST_EDIT;
if (editSelIndex >= favCount) editSelIndex = 0;
editScrollPos = 0;
lastEditScrollMs = millis();
if (!lcdCanWrite()) return;
lcd.setCursor(0, 0);
if (favCount == 0) {
lcd.print(F("EDIT FAVS EMPTY "));
} else {
char l1[17];
snprintf(l1, sizeof(l1), "EDIT FAV (%u)/%u", (unsigned)(editSelIndex + 1), (unsigned)favCount);
printPad16(l1);
}
int len = favVirtualLen();
if (len <= 0) len = 1;
for (byte i = 0; i < 16; i++) editWin[i] = favCharAt((editScrollPos + i) % len);
editWin[16] = '\0';
lcd.setCursor(0, 1);
lcd.print(editWin);
}
void exitFavListEditToFavMode() { favEnterEditMode(); }
void favListSelectPrev() {
if (favCount == 0) return;
if (editSelIndex == 0) editSelIndex = favCount - 1;
else editSelIndex--;
if (!lcdCanWrite()) return;
lcd.setCursor(0, 0);
char l1[17];
snprintf(l1, sizeof(l1), "EDIT FAV (%u)/%u", (unsigned)(editSelIndex + 1), (unsigned)favCount);
printPad16(l1);
}
void favListSelectNext() {
if (favCount == 0) return;
editSelIndex++;
if (editSelIndex >= favCount) editSelIndex = 0;
if (!lcdCanWrite()) return;
lcd.setCursor(0, 0);
char l1[17];
snprintf(l1, sizeof(l1), "EDIT FAV (%u)/%u", (unsigned)(editSelIndex + 1), (unsigned)favCount);
printPad16(l1);
}
void enterFavItemEditMode() {
if (favCount == 0) return;
mode = MODE_FAV_ITEM_EDIT;
clearBuffer();
uint16_t v = favList[editSelIndex];
char s[4];
snprintf(s, sizeof(s), "%03u", (unsigned)v);
buf[0] = s[0]; buf[1] = s[1]; buf[2] = s[2]; buf[3] = '\0';
bufLen = 3;
entryDirty = true;
if (!lcdCanWrite()) return;
lcd.setCursor(0, 0);
char l1[17];
snprintf(l1, sizeof(l1), "EDIT ITEM (%u)/%u", (unsigned)(editSelIndex + 1), (unsigned)favCount);
printPad16(l1);
lcd.setCursor(0, 1);
lcd.print(F("VAL:___ OK:C "));
lcd.setCursor(4, 1);
lcd.print(buf[0]); lcd.print(buf[1]); lcd.print(buf[2]);
favCursorBlinkState = true;
lastFavCursorBlinkMs = millis();
}
void favItemEditCursorBlink() {
if (!lcdCanWrite()) return;
if (bufLen >= 3) return;
if (millis() - lastFavCursorBlinkMs >= CURSOR_BLINK_MS) {
lastFavCursorBlinkMs = millis();
favCursorBlinkState = !favCursorBlinkState;
byte col;
if (bufLen == 0) col = 6;
else if (bufLen == 1) col = 5;
else col = 4;
lcd.setCursor(col, 1);
lcd.print(favCursorBlinkState ? "_" : " ");
}
}
void favItemEditRedrawDigits() {
if (!lcdCanWrite()) return;
lcd.setCursor(0, 1);
lcd.print(F("VAL:___ OK:C "));
char d0 = '_', d1 = '_', d2 = '_';
if (bufLen == 1) d2 = buf[0];
else if (bufLen == 2) { d1 = buf[0]; d2 = buf[1]; }
else if (bufLen >= 3) { d0 = buf[bufLen - 3]; d1 = buf[bufLen - 2]; d2 = buf[bufLen - 1]; }
lcd.setCursor(4, 1);
lcd.print(d0); lcd.print(d1); lcd.print(d2);
}
void saveFavItemEdit() {
if (favCount == 0) { enterFavListEditMode(); return; }
uint16_t v = bufferToNumber();
if (v > 255) {
startAlertScreen("ERR: 0-255 ONLY", "NOT SAVED", 1200, 250);
mode = MODE_NORMAL;
return;
}
favList[editSelIndex] = v;
saveFavoritesToEEPROM();
enterFavListEditMode();
}
// =========================================================
// SLEEP / DIM CONTROL
// =========================================================
void wakeDisplayFromKeypress() {
if (displaySleeping) {
lcd.display();
lcd.backlight();
displaySleeping = false;
}
if (dimmingActive) {
dimmingActive = false;
}
lcdWritesFrozen = false;
lcd.backlight();
}
void noteKeypadTouch() {
lastKeyTouchMs = millis();
wakeDisplayFromKeypress();
}
void startDimmingNow() {
dimmingActive = true;
dimStartMs = millis();
lastDimPwmMs = 0;
dimPwmPhase = 0;
lcdWritesFrozen = true;
}
void goToSleepNow() {
lcd.noBacklight();
lcd.noDisplay();
displaySleeping = true;
dimmingActive = false;
lcdWritesFrozen = true;
}
void updateBacklightSleep() {
unsigned long now = millis();
if (displaySleeping) return;
if (!dimmingActive) {
if (lastKeyTouchMs == 0) lastKeyTouchMs = now;
if ((unsigned long)(now - lastKeyTouchMs) >= LCD_IDLE_BEFORE_DIM_MS) {
startDimmingNow();
}
return;
}
unsigned long elapsed = now - dimStartMs;
if (elapsed >= LCD_DIM_DURATION_MS) {
goToSleepNow();
return;
}
uint32_t b = 255UL - (255UL * (uint32_t)elapsed) / (uint32_t)LCD_DIM_DURATION_MS;
byte brightness = (byte)b;
if (lastDimPwmMs == 0) lastDimPwmMs = now;
if ((unsigned long)(now - lastDimPwmMs) < DIM_PWM_PERIOD_MS) return;
lastDimPwmMs += DIM_PWM_PERIOD_MS;
dimPwmPhase++;
if (dimPwmPhase < brightness) lcd.backlight();
else lcd.noBacklight();
}
// -------------------- Keypad event handler --------------------
void keypadEvent(KeypadEvent k) {
KeyState st = keypad.getState();
if (st == PRESSED) {
// WAKE-ONLY press
if (displaySleeping || dimmingActive || lcdWritesFrozen) {
lastKeyTouchMs = millis();
wakeDisplayFromKeypress();
return;
}
noteKeypadTouch();
// Key click tone (D1 passive)
triggerKeyPulse();
if (alertActive) return;
appliedUntilMs = 0;
errorUntilMs = 0;
// ✅ AUTH MODES FIRST
if (mode == MODE_FAV_ERASE_AUTH) {
if (k >= '0' && k <= '9') {
pwLastInputMs = millis();
if (pwLen < PW_LEN) {
pwBuf[pwLen++] = k;
pwBuf[pwLen] = '\0';
regenPwMaskAll();
showEraseMaskedQuiet();
if (pwLen >= PW_LEN) {
if (strncmp(pwBuf, PW_CODE, PW_LEN) == 0) eraseAuthSuccess();
else eraseAuthFailStart();
}
}
return;
}
if (k == '#') {
pwLastInputMs = millis();
eraseAuthBackspace();
return;
}
return;
}
if (mode == MODE_FAV_LIST_EDIT_AUTH) {
if (k >= '0' && k <= '9') {
pwLastInputMs = millis();
if (pwLen < PW_LEN) {
pwBuf[pwLen++] = k;
pwBuf[pwLen] = '\0';
regenPwMaskAll();
showFavListEditMaskedQuiet();
if (pwLen >= PW_LEN) {
if (strncmp(pwBuf, PW_CODE, PW_LEN) == 0) {
startSuccessTone();
enterFavListEditMode();
} else {
favListAuthFailStart();
}
}
}
return;
}
if (k == '#') {
pwLastInputMs = millis();
favListAuthBackspace();
return;
}
return;
}
// Long-press tracking keys
if (k == '*') { starDown = true; starDownStartMs = millis(); starLongFired = false; return; }
if (k == '#') { if (mode != MODE_STEP) { hashDown = true; hashDownStartMs = millis(); hashLongFired = false; } return; }
if (k == 'A') { aDown = true; aDownStartMs = millis(); aLongFired = false; return; }
if (k == 'B') { bDown = true; bDownStartMs = millis(); bLongFired = false; return; }
if (k == 'C') { cDown = true; cDownStartMs = millis(); cLongFired = false; return; }
if (k == 'D') { dDown = true; dDownStartMs = millis(); dLongFired = false; return; }
// List edit navigation
if (mode == MODE_FAV_LIST_EDIT) {
if (k == '2' || k == '4') { favListSelectPrev(); return; }
if (k == '6' || k == '8') { favListSelectNext(); return; }
return;
}
// Item edit typing
if (mode == MODE_FAV_ITEM_EDIT) {
if (k >= '0' && k <= '9') {
if (bufLen >= 3) { clearBuffer(); }
appendDigit(k);
favItemEditRedrawDigits();
return;
}
if (k == '#') { backspaceDigit(); favItemEditRedrawDigits(); return; }
return;
}
// Favorites add mode typing
if (mode == MODE_FAV_EDIT) {
if (k >= '0' && k <= '9') { appendDigit(k); favDrawTypingLine2Once(); return; }
if (k == '#') {
backspaceDigit();
if (bufLen) favDrawTypingLine2Once();
else favDrawPromptOnce();
return;
}
return;
}
if (mode == MODE_FAV_PLAY) return;
// Repeat mode: digit exits repeat and continues to typing
if (mode == MODE_REPEAT && (k >= '0' && k <= '9')) {
mode = MODE_NORMAL;
setOutputsFromValue(0);
pulseUntilMs = 0;
playingValue = 0;
clearLine2();
scrollPos = 0;
lastScrollMs = millis();
}
// Normal typing
if (k >= '0' && k <= '9') {
if (mode == MODE_STEP) {
mode = MODE_NORMAL;
initLine1();
setOutputsFromValue(0);
pulseUntilMs = 0;
playingValue = 0;
}
appendDigit(k);
typingUntilMs = millis() + TYPING_HOLD_MS;
showTypedDigitsLine2();
scrollPos = 0;
lastScrollMs = millis();
return;
}
return;
}
if (st == RELEASED) {
if (alertActive) return;
if (k == '*') {
starDown = false;
if (starLongFired) return;
if (mode == MODE_FAV_ERASE_AUTH || mode == MODE_FAV_LIST_EDIT_AUTH) {
favEnterEditMode();
return;
}
if (mode == MODE_FAV_PLAY || mode == MODE_FAV_EDIT ||
mode == MODE_FAV_LIST_EDIT || mode == MODE_FAV_ITEM_EDIT) {
if (mode == MODE_FAV_LIST_EDIT || mode == MODE_FAV_ITEM_EDIT) exitFavListEditToFavMode();
else fullResetToNormal();
return;
}
fullResetToNormal();
return;
}
if (k == '#') {
hashDown = false;
if (hashLongFired) return;
if (mode == MODE_STEP) return;
if (mode == MODE_FAV_EDIT || mode == MODE_FAV_PLAY || mode == MODE_FAV_ERASE_AUTH ||
mode == MODE_FAV_LIST_EDIT_AUTH || mode == MODE_FAV_LIST_EDIT || mode == MODE_FAV_ITEM_EDIT) return;
if (entryDirty) {
uint16_t v = bufferToNumber();
clearBuffer();
typingUntilMs = 0;
if (v <= 255) startPulsePlay(v);
else {
lastErrorValue = v;
errorUntilMs = millis() + ERROR_SHOW_MS;
errorScrollPos = 0;
clearLine2();
lastScrollMs = millis();
}
return;
}
if (mode == MODE_NORMAL && playingValue == 0) {
if (forgetPrev) fullResetToNormal();
else startPulsePlay(lastPlayedValue);
}
return;
}
if (k == 'A') {
aDown = false;
if (!aLongFired && mode == MODE_STEP) stepAdvanceOnTrig();
return;
}
if (k == 'B') {
bDown = false;
if (bLongFired) return;
if (mode == MODE_FAV_PLAY) { favAdvance(); return; }
if (mode == MODE_FAV_EDIT) {
if (!entryDirty || bufLen == 0) {
favDrawPromptOnce();
if (lcdCanWrite()) {
lcd.setCursor(0, 1);
lcd.print(F("TYPE 0-255 "));
}
favMsgUntilMs = millis() + 900;
return;
}
uint16_t v = bufferToNumber();
clearBuffer();
if (v <= 255) {
if (favAdd(v)) {
favDrawLine1Once();
if (lcdCanWrite()) { lcd.setCursor(0, 1); lcd.print(F("SAVED ")); }
favMsgUntilMs = millis() + 700;
} else {
favDrawLine1Once();
if (lcdCanWrite()) { lcd.setCursor(0, 1); lcd.print(F("FAV FULL (MAX10)")); }
favMsgUntilMs = millis() + 1200;
}
} else {
favDrawLine1Once();
if (lcdCanWrite()) { lcd.setCursor(0, 1); lcd.print(F("ERR: 0-255 ONLY ")); }
favMsgUntilMs = millis() + 1200;
}
return;
}
return;
}
if (k == 'C') {
cDown = false;
if (cLongFired) return;
if (mode == MODE_FAV_LIST_EDIT) { enterFavItemEditMode(); return; }
if (mode == MODE_FAV_ITEM_EDIT) { saveFavItemEdit(); return; }
return;
}
if (k == 'D') {
dDown = false;
if (!dLongFired) {
if (mode == MODE_FAV_EDIT) favShowCountAndLast();
}
return;
}
}
}
// -------------------- Setup --------------------
void setup() {
for (byte i = 0; i < 8; i++) pinMode(outPins[i], OUTPUT);
pinMode(TRIG_PIN, INPUT);
pinMode(KEYPULSE_PIN, OUTPUT);
setOutputsFromValue(0);
lcd.init();
lcd.backlight();
lcd.display();
randomSeed(analogRead(A6));
loadFavoritesFromEEPROM();
initLine1();
clearLine2();
keypad.addEventListener(keypadEvent);
keypad.setHoldTime(200);
lastScrollMs = millis();
bool r = digitalRead(TRIG_PIN);
trigRawPrev = r;
trigStable = r;
trigStablePrev = r;
trigLastChangeMs = millis();
pwBuf[0] = '\0';
pwMask[0] = '\0';
pwLen = 0;
lastKeyTouchMs = millis();
}
// -------------------- Loop --------------------
void loop() {
keypad.getKey();
updateBacklightSleep();
updateAlarmTone();
updateSuccessTone();
if (alertActive) {
updateAlertScreen();
if (!alertActive) {
stopAlarmTone();
initLine1();
clearLine2();
resetVisualTimers();
if (pwWrongFlag == 2) { pwWrongFlag = 0; enterFavListEditAuthMode(); }
else if (pwWrongFlag == 1) { pwWrongFlag = 0; enterEraseAuthMode(); }
}
return;
}
if (mode == MODE_FAV_ERASE_AUTH || mode == MODE_FAV_LIST_EDIT_AUTH) {
if (pwLastInputMs && (unsigned long)(millis() - pwLastInputMs) >= PW_IDLE_TIMEOUT_MS) {
favEnterEditMode();
}
}
if (starDown && !starLongFired && (millis() - starDownStartMs >= STAR_HOLD_MS)) {
starLongFired = true;
longStarForgetReset();
hashDown = false; hashLongFired = true;
aDown = false; aLongFired = true;
bDown = false; bLongFired = true;
cDown = false; cLongFired = true;
dDown = false; dLongFired = true;
}
if (aDown && !aLongFired && (millis() - aDownStartMs >= A_HOLD_MS)) {
aLongFired = true;
uint16_t startVal;
if (entryDirty && bufLen > 0) {
uint16_t typedVal = bufferToNumber();
startVal = (typedVal > 0) ? typedVal : 1;
} else {
startVal = (forgetPrev) ? 1 : ((lastPlayedValue > 0) ? lastPlayedValue : 1);
}
enterStepMode(startVal);
hashDown = false;
hashLongFired = true;
}
if (bDown && !bLongFired && (millis() - bDownStartMs >= B_HOLD_MS)) {
bLongFired = true;
if (mode == MODE_FAV_PLAY) {
fullResetToNormal();
} else if (mode == MODE_FAV_EDIT) {
favEnterPlayMode();
} else if (mode == MODE_FAV_ERASE_AUTH) {
fullResetToNormal();
} else if (mode == MODE_FAV_LIST_EDIT_AUTH || mode == MODE_FAV_LIST_EDIT || mode == MODE_FAV_ITEM_EDIT) {
exitFavListEditToFavMode();
} else {
setOutputsFromValue(0);
pulseUntilMs = 0;
favEnterEditMode();
}
hashDown = false; hashLongFired = true;
dDown = false; dLongFired = true;
}
if (cDown && !cLongFired && (millis() - cDownStartMs >= C_HOLD_MS)) {
cLongFired = true;
if (mode == MODE_FAV_LIST_EDIT || mode == MODE_FAV_ITEM_EDIT || mode == MODE_FAV_LIST_EDIT_AUTH) {
exitFavListEditToFavMode();
} else if (mode == MODE_FAV_EDIT) {
enterFavListEditAuthMode();
}
}
if (mode != MODE_FAV_EDIT && mode != MODE_FAV_PLAY && mode != MODE_FAV_ERASE_AUTH &&
mode != MODE_FAV_LIST_EDIT_AUTH && mode != MODE_FAV_LIST_EDIT && mode != MODE_FAV_ITEM_EDIT) {
if (hashDown && !hashLongFired && (millis() - hashDownStartMs >= HASH_HOLD_MS)) {
hashLongFired = true;
if (mode != MODE_STEP) {
uint16_t v = entryDirty ? bufferToNumber() : (forgetPrev ? 0 : lastPlayedValue);
if (v <= 255) enterRepeatMode(v);
else {
lastErrorValue = v;
errorUntilMs = millis() + ERROR_SHOW_MS;
errorScrollPos = 0;
clearLine2();
lastScrollMs = millis();
}
}
}
}
if (dDown && !dLongFired && (millis() - dDownStartMs >= D_HOLD_MS)) {
dLongFired = true;
if (mode == MODE_FAV_EDIT) {
enterEraseAuthMode();
}
}
if (pulseUntilMs && millis() >= pulseUntilMs) {
pulseUntilMs = 0;
if (mode != MODE_REPEAT) setOutputsFromValue(0);
}
// ---------------- D0: STRICT LOW->HIGH ONLY (debounced stable) ----------------
{
bool raw = digitalRead(TRIG_PIN);
if (raw != trigRawPrev) {
trigRawPrev = raw;
trigLastChangeMs = millis();
}
if ((millis() - trigLastChangeMs) >= TRIG_DEBOUNCE_MS) {
if (trigStable != trigRawPrev) {
trigStable = trigRawPrev;
if (trigStablePrev == LOW && trigStable == HIGH) {
if (mode == MODE_STEP) stepAdvanceOnTrig();
else if (mode == MODE_FAV_PLAY) favAdvance();
else if (mode == MODE_REPEAT) { /* ignored */ }
else fullResetToNormal();
}
trigStablePrev = trigStable;
}
}
}
if (lcdWritesFrozen) return;
if (mode == MODE_FAV_ERASE_AUTH) { erasePwCursorUpdate(); return; }
if (mode == MODE_FAV_LIST_EDIT_AUTH) { favListPwCursorUpdate(); return; }
if (mode == MODE_FAV_LIST_EDIT) {
if (millis() - lastEditScrollMs >= 260) {
lastEditScrollMs = millis();
int len = favVirtualLen();
if (len <= 0) len = 1;
if (editScrollPos >= len) editScrollPos = 0;
for (byte i = 0; i < 16; i++) editWin[i] = favCharAt((editScrollPos + i) % len);
editWin[16] = '\0';
lcd.setCursor(0, 1);
lcd.print(editWin);
editScrollPos++;
}
return;
}
if (mode == MODE_FAV_ITEM_EDIT) {
favItemEditCursorBlink();
return;
}
if (mode == MODE_FAV_EDIT) {
if (favMsgUntilMs && millis() >= favMsgUntilMs) {
favMsgUntilMs = 0;
favBlinkState = true;
lastFavBlinkMs = millis();
favCursorBlinkState = true;
lastFavCursorBlinkMs = millis();
favBBlinkState = true;
lastFavBBlinkMs = millis();
favDrawPromptOnce();
}
if (favMsgUntilMs) return;
if (bufLen > 0) {
favDrawTypingLine2Once();
favBlinkTypingCursorUpdate();
favBlinkTypingSaveUpdate();
} else {
if (favTypingView) {
favBlinkState = true;
lastFavBlinkMs = millis();
favDrawPromptOnce();
}
favBlinkPromptUpdate();
}
return;
}
if (mode == MODE_FAV_PLAY) {
if (favCount == 0) { fullResetToNormal(); return; }
if (millis() - lastScrollMs >= SCROLL_STEP_MS) {
lastScrollMs = millis();
buildFavNowText(scrollBuf, sizeof(scrollBuf), favIndex, favCount, playingValue);
showScrollWindow(scrollBuf, scrollPos);
scrollPos++;
}
return;
}
if (mode != MODE_STEP) updateLine1Blink();
if (errorUntilMs) {
if (millis() < errorUntilMs) {
if (millis() - lastScrollMs >= ERROR_SCROLL_STEP_MS) {
lastScrollMs = millis();
buildErrorScrollText(scrollBuf, sizeof(scrollBuf), lastErrorValue);
showScrollWindow(scrollBuf, errorScrollPos);
errorScrollPos++;
}
return;
} else {
errorUntilMs = 0;
clearLine2();
scrollPos = 0;
lastScrollMs = millis();
}
}
if (appliedUntilMs) {
if (millis() < appliedUntilMs) return;
appliedUntilMs = 0;
clearLine2();
scrollPos = 0;
lastScrollMs = millis();
}
if (millis() < typingUntilMs) {
showTypedDigitsLine2();
return;
}
if (millis() - lastScrollMs >= SCROLL_STEP_MS) {
lastScrollMs = millis();
if (mode == MODE_STEP) buildStepScrollText(scrollBuf, sizeof(scrollBuf), stepCurrent);
else if (mode == MODE_REPEAT) buildRepeatScrollText(scrollBuf, sizeof(scrollBuf), playingValue);
else buildNormalScrollText(scrollBuf, sizeof(scrollBuf), playingValue);
showScrollWindow(scrollBuf, scrollPos);
scrollPos++;
}
}