// LED Fret Marker Controller
// A DIY project for adding RGB LED fret markers to your guitar or bass.
//
// Install the LEDs in the fretboard and wire them in parallel.
// Place the current-limiting resistors inline on the RGB channels.
// Mount the Arduino Nano and 9V battery in the control cavity.
// Wire the colour and mode buttons to wherever makes sense for your build.
// The OLED display is optional but recommended — mount it somewhere visible.
// For this build, the buttons, display and slide switch are recessed into a
// 3D-printed insert on the rear control cavity cover to prevent accidental activation.
//
// Components are available from vendors such as Amazon or AliExpress.
//
// For a simple project without a display screen see:
// https://wokwi.com/projects/466029122861837313
//
// For an enhanced project which includes a display screen see:
// https://wokwi.com/projects/465892077278212097
//
// Display: SSD1306 0.96" 128x64 OLED, I2C, address 0x3C
// SDA -> A4, SCL -> A5, VCC -> 5V, GND -> GND
// Note: driver initialised as 128x32 to match the layout configuration.
//
// SPDX-License-Identifier: CC0-1.0
// Released to the public domain. No rights reserved.
// You are free to use, copy, modify, and distribute this code for any purpose,
// with or without attribution.
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define OLED_ADDR 0x3C
Adafruit_SSD1306 display(128, 32, &Wire, -1);
// ---- Pin assignments ----
const int PIN_RED = 9; // PWM pin for red channel — must be a ~ pin
const int PIN_GREEN = 10; // PWM pin for green channel — must be a ~ pin
const int PIN_BLUE = 11; // PWM pin for blue channel — must be a ~ pin
const int PIN_BTN_COLOUR = 2; // Momentary button: cycles through colours
const int PIN_BTN_MODE = 3; // Momentary button: cycles through modes
const int PIN_BTN_DISPLAY = 4; // Momentary button: toggles display on/off
// ---- Defaults ----
// These determine the state on power-on.
// DEFAULT_COLOUR: index into COLOURS array (0=Red, 1=Orange... 9=Rainbow)
// DEFAULT_MODE: 0=Static, 1=Breathe, 2=Strobe, 3=Disco, 4=Fire, 5=Police
const int DEFAULT_COLOUR = 0;
const int DEFAULT_MODE = 0;
// ---- LED animation constants ----
// BREATHE_MIN: the dimmest point of the breathe fade, as a fraction of full brightness.
// 0.0 = fades to fully off. 0.25 = fades to 25% brightness. 1.0 = no fade (effectively static).
// Practical range: 0.0–0.5. Values above 0.5 give a very subtle pulse.
const float BREATHE_MIN = 0.25;
// BREATHE_MAX: the brightest point of the breathe fade, as a fraction of full brightness.
// 1.0 = full brightness at peak. Reduce slightly (e.g. 0.8) for a softer overall glow.
// Must be greater than BREATHE_MIN. Practical range: 0.5–1.0.
const float BREATHE_MAX = 1.0;
// BREATHE_STEP: how much brightness changes per tick. Controls the speed of the fade.
// Smaller = slower, smoother fade. Larger = faster, more abrupt pulse.
// Each tick is BREATHE_TICK ms apart, so total fade time ≈ (MAX-MIN)/STEP * TICK ms.
// At 0.005 and 6ms tick: one full sweep (dim→bright) takes ~900ms.
// Practical range: 0.001 (very slow, dreamy) to 0.02 (fast, aggressive).
const float BREATHE_STEP = 0.005;
// BREATHE_TICK: milliseconds between each brightness step in breathe mode.
// Lower = smoother animation (more updates per second) but more CPU time spent on display.
// Higher = choppier fade but less CPU load.
// Practical range: 4ms (very smooth) to 20ms (noticeable stepping).
const uint32_t BREATHE_TICK = 6;
// STROBE_ON_MS: how long the LEDs stay ON during each strobe flash, in milliseconds.
// Lower = shorter, sharper flash. Higher = longer flash, more like a slow blink.
// Practical range: 20ms (very brief flash) to 200ms (slow blink).
const uint32_t STROBE_ON_MS = 60;
// STROBE_OFF_MS: how long the LEDs stay OFF between strobe flashes, in milliseconds.
// Controls the gap between flashes. Equal to STROBE_ON_MS = symmetric strobe.
// Longer OFF than ON gives a more strobe-like feel. Shorter OFF = rapid flickering.
// Practical range: 40ms (fast, frantic) to 500ms (slow, dramatic).
const uint32_t STROBE_OFF_MS = 120;
// RAINBOW_STEP_MS: milliseconds between each 1-degree hue rotation in rainbow mode.
// Lower = faster colour cycling. Higher = slower, more gradual rotation.
// At 20ms: full 360° colour cycle takes 7.2 seconds.
// Practical range: 5ms (fast spin) to 100ms (very slow drift).
const uint32_t RAINBOW_STEP_MS = 20;
// DISCO_TICK_MS: milliseconds between each colour switch in disco mode.
// Lower = faster, more frantic switching. Higher = slower, more deliberate.
// Practical range: 50ms (very fast) to 500ms (slow flash).
const uint32_t DISCO_TICK_MS = 150;
// FIRE_TICK_MS: milliseconds between each fire flicker update.
// Lower = faster, more frantic flicker. Higher = slower, lazier flame.
// Practical range: 30ms (aggressive) to 120ms (slow burn).
const uint32_t FIRE_TICK_MS = 50;
// FIRE_BRIGHTNESS_MIN: minimum brightness of the fire flicker (0.0–1.0).
// Higher values prevent the flame from going too dim between flickers.
// Practical range: 0.3 (dramatic dips) to 0.7 (steady with subtle flicker).
const float FIRE_BRIGHTNESS_MIN = 0.4;
// POLICE_FLASH_MS: duration of each individual red or blue flash, in milliseconds.
// Controls how fast the red/blue alternation happens within each burst.
// Practical range: 60ms (very fast) to 200ms (slow, deliberate).
const uint32_t POLICE_FLASH_MS = 80;
// POLICE_BURST_COUNT: number of flashes per colour before switching to the other colour.
// 3 = three red flashes then three blue flashes, repeat.
// Practical range: 1 (simple alternation) to 5 (longer burst sequence).
const uint8_t POLICE_BURST_COUNT = 3;
// POLICE_GAP_MS: dark pause between red burst and blue burst, in milliseconds.
// 0 = no gap, immediate switch. Higher values add a brief blackout between colours.
// Practical range: 0ms (no gap) to 150ms (noticeable pause).
const uint32_t POLICE_GAP_MS = 60;
// ---- Debounce ----
// DEBOUNCE_MS: minimum time in ms a button must be held before release registers.
// Prevents false triggers from electrical noise on button press.
// 50ms suits real hardware. If buttons feel unresponsive, lower to 30ms.
// If buttons occasionally double-fire, raise to 80ms.
const uint32_t DEBOUNCE_MS = 50;
// ---- Display ----
// SCREENSAVER_MS: milliseconds of inactivity before the screensaver activates.
// 3000 = 3 seconds after last button press. Raise for longer status display dwell.
const uint32_t SCREENSAVER_MS = 3000;
// SCROLL_TICK_MS: milliseconds between each 2-pixel scroll step of the banner.
// Lower = faster scroll. Higher = slower scroll.
// At 30ms per 2px step: scroll speed is ~67px/second.
// Practical range: 15ms (fast) to 80ms (slow, readable).
const uint32_t SCROLL_TICK_MS = 30;
// ---- Battery monitoring ----
// VCC threshold in volts below which battery is considered low.
// The Nano regulator needs ~7V in to maintain 5V out. As the PP3 drains,
// the regulated rail drops below 5V. 4.5V is a safe low-battery threshold.
const float BATTERY_LOW_V = 4.5;
// How often to sample VCC, in milliseconds. Sampling too frequently wastes
// CPU cycles; every 30 seconds is plenty for a battery indicator.
const uint32_t BATTERY_CHECK_MS = 30000;
// ---- Screensaver modes ----
// Add new screensaver IDs here as you build them, e.g.:
// #define SS_BOUNCING_DOT 1
// #define SS_STARFIELD 2
#define SS_SCROLL_BANNER 0
const int SCREENSAVER_MODE = SS_SCROLL_BANNER; // which screensaver to show
// ---- Colours ----
const uint8_t COLOURS[][3] = {
{255, 0, 0}, // 0 Red
{255, 80, 0}, // 1 Orange
{255, 220, 0}, // 2 Yellow
{ 0, 255, 0}, // 3 Green
{ 0, 220, 220}, // 4 Cyan
{ 0, 0, 255}, // 5 Blue
{ 80, 0, 200}, // 6 Indigo
{180, 0, 255}, // 7 Violet
{255, 255, 255}, // 8 White
{ 0, 0, 0}, // 9 Rainbow (placeholder — colour computed via HSV at runtime)
};
const char* COLOUR_NAMES[] = {
"Red","Orange","Yellow","Green","Cyan",
"Blue","Indigo","Violet","White","Rainbow"
};
const int NUM_COLOURS = 10;
const int RAINBOW_INDEX = 9; // index of the special rainbow entry
const int NUM_MODES = 6;
const char* MODE_NAMES[] = { "Static", "Breathe", "Strobe", "Disco", "Fire", "Police" };
// ---- Runtime state ----
int currentColour = DEFAULT_COLOUR;
int currentMode = DEFAULT_MODE;
bool pwmDirty = true; // true = force a PWM write on next loop, even if value unchanged
bool lastColourPin = HIGH;
bool lastModePin = HIGH;
uint32_t colourLockUntil = 0; // millis() timestamp: ignore button until this time (debounce)
uint32_t modeLockUntil = 0;
bool lastDisplayPin = HIGH;
uint32_t displayLockUntil = 0;
float breatheBrightness = BREATHE_MIN; // current brightness level in breathe mode (0.0–1.0)
float breatheStep = BREATHE_STEP; // current direction: positive = brightening, negative = dimming
uint32_t lastBreatheStep = 0;
bool strobeOn = false; // current strobe state: true = LEDs on, false = LEDs off
uint32_t lastStrobeTime = 0;
uint32_t lastDiscoTick = 0; // millis() of last disco colour switch
int8_t discoColour = -1; // currently displayed disco colour index
int8_t lastDiscoColour = -1; // previous disco colour (used to avoid repeats)
uint16_t rainbowHue = 0; // current hue in degrees (0–359)
uint32_t lastRainbowStep = 0;
uint8_t lastR = 0, lastG = 0, lastB = 0; // last values written to PWM pins (used to suppress redundant writes)
// ---- Fire state ----
uint32_t lastFireTick = 0;
float fireBrightness = 1.0; // current flicker brightness (0.0–1.0)
// Fire flickers randomly between red, orange and yellow at random brightness.
// The three base fire colours, blended by random selection each tick:
const uint8_t FIRE_COLOURS[][3] = {
{255, 20, 0}, // deep red-orange
{255, 80, 0}, // orange
{255, 160, 0}, // amber
{255, 220, 0}, // yellow
};
const uint8_t NUM_FIRE_COLOURS = 4;
uint8_t fireColourIndex = 0; // which fire colour is currently showing
// ---- Police state ----
// Police cycles: [red burst] [gap] [blue burst] [gap] repeat
// State machine: 0 = red flashing, 1 = gap after red,
// 2 = blue flashing, 3 = gap after blue
uint32_t lastPoliceTick = 0;
uint8_t policeState = 0; // 0=red burst, 1=gap, 2=blue burst, 3=gap
uint8_t policeFlashCount = 0; // flashes fired in current burst
bool policeFlashOn = false; // current flash state within a burst
// ---- Display state ----
bool displayOK = false; // set true if display initialises successfully
bool displayEnabled = true; // toggled by PIN_BTN_DISPLAY; false = display blanked
bool batteryLow = false; // set true when VCC drops below BATTERY_LOW_V
uint32_t lastBatteryCheck = 0; // millis() of last VCC sample
// Screensaver slot counter. Incremented on each banner pick.
// Odd values show the battery warning banner when batteryLow is true.
uint8_t screensaverSlot = 0;
bool screensaverActive = false;
uint32_t lastActivityTime = 0; // millis() of last button press
// ---- Scroll banner ----
// Add or remove strings freely — NUM_BANNERS updates automatically.
// Each string gets 3 trailing spaces appended at render time for inter-banner gap.
const char* BANNER_TEXTS[] = {
"NORMAL INNOCENT MEN",
"NINE INCH MALES",
"NEARLY IMPRESSIVE MUSICIANS",
"NUDITY IS MANDATORY",
"NIPPLES IN MOTION",
"NOISY IRRITATING MEN",
"NOBODY'S INTERESTED, MATE",
"NONCES? INCONCLUSIVE, MAYBE",
"NOT INEXPERIENCED, MASTURBATIONALLY",
};
const uint8_t NUM_BANNERS = sizeof(BANNER_TEXTS) / sizeof(BANNER_TEXTS[0]);
// BANNER_SIZE: Adafruit GFX text size multiplier.
// Each unit multiplies the base 5x7 glyph. Size 4 = 20x28px per char.
// At size 4, text is 28px tall — fits cleanly in the 32px display height.
// Size 3 = 21px tall (more headroom). Size 5 = 35px tall (overflows, not recommended).
const uint8_t BANNER_SIZE = 4;
int16_t scrollX = 128; // current X position of banner text (starts off right edge)
uint32_t lastScrollTick = 0;
int8_t currentBanner = -1; // index of currently scrolling string (-1 = not yet chosen)
int8_t previousBanner = -1; // index of last shown string (used to avoid immediate repeat)
int16_t textWidth = 0; // pixel width of current banner string (computed on pick)
// Battery warning banner — shown on odd screensaver slots when batteryLow is true
const char* BATTERY_WARNING_TEXT = "BATTERY LOW";
// Forward declarations
void drawScrollBanner();
void drawScreensaver();
void drawStatus();
// Pick the next banner to display.
// Every other slot (odd screensaverSlot) shows the battery warning when batteryLow is true.
// Existing random non-repeating logic is preserved for all normal slots.
void pickNextBanner() {
screensaverSlot++;
if (batteryLow && (screensaverSlot % 2 == 1)) {
currentBanner = -2; // sentinel: -2 = battery warning
textWidth = (int16_t)((strlen(BATTERY_WARNING_TEXT) + 3) * 6 * BANNER_SIZE);
return;
}
if (NUM_BANNERS == 1) {
currentBanner = 0;
} else {
int8_t pick;
do {
pick = (int8_t)random(NUM_BANNERS);
} while (pick == previousBanner);
previousBanner = pick;
currentBanner = pick;
}
textWidth = (int16_t)((strlen(BANNER_TEXTS[currentBanner]) + 3) * 6 * BANNER_SIZE);
}
// -------------------------------------------------------
// Battery voltage measurement — non-blocking
// -------------------------------------------------------
enum VCCState { VCC_IDLE, VCC_SETTLING, VCC_CONVERTING };
VCCState vccState = VCC_IDLE;
uint32_t vccSettleStart = 0;
bool readVCCNonBlocking(float &vcc) {
switch (vccState) {
case VCC_IDLE:
ADMUX = _BV(REFS0) | _BV(MUX3) | _BV(MUX2) | _BV(MUX1);
vccSettleStart = millis();
vccState = VCC_SETTLING;
return false;
case VCC_SETTLING:
if (millis() - vccSettleStart < 2) return false;
ADCSRA |= _BV(ADSC);
vccState = VCC_CONVERTING;
return false;
case VCC_CONVERTING:
if (bit_is_set(ADCSRA, ADSC)) return false;
uint16_t result = ADCL;
result |= ADCH << 8;
vcc = 1125300.0 / result / 1000.0;
vccState = VCC_IDLE;
return true;
}
return false;
}
// -------------------------------------------------------
void hsvToRgb(float h, float s, float v,
uint8_t &r, uint8_t &g, uint8_t &b) {
float c = v * s;
float x = c * (1.0 - fabs(fmod(h / 60.0, 2.0) - 1.0));
float m = v - c;
float r1, g1, b1;
if (h < 60) { r1=c; g1=x; b1=0; }
else if (h < 120) { r1=x; g1=c; b1=0; }
else if (h < 180) { r1=0; g1=c; b1=x; }
else if (h < 240) { r1=0; g1=x; b1=c; }
else if (h < 300) { r1=x; g1=0; b1=c; }
else { r1=c; g1=0; b1=x; }
r = (uint8_t)((r1 + m) * 255.0);
g = (uint8_t)((g1 + m) * 255.0);
b = (uint8_t)((b1 + m) * 255.0);
}
void setColour(uint8_t r, uint8_t g, uint8_t b) {
if (pwmDirty || r != lastR || g != lastG || b != lastB) {
analogWrite(PIN_RED, r);
analogWrite(PIN_GREEN, g);
analogWrite(PIN_BLUE, b);
lastR = r; lastG = g; lastB = b;
pwmDirty = false;
}
}
void ledsOff() { setColour(0, 0, 0); }
// -------------------------------------------------------
// Status screen
// -------------------------------------------------------
void drawStatus() {
if (!displayOK || !displayEnabled) return;
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(2);
display.setCursor(0, 0);
display.print(COLOUR_NAMES[currentColour]);
Serial.print("Colour: "); Serial.println(COLOUR_NAMES[currentColour]);
display.setTextSize(2);
display.setCursor(0, 18);
display.print(MODE_NAMES[currentMode]);
if (batteryLow) display.print(" BAT");
Serial.print("Mode: "); Serial.println(MODE_NAMES[currentMode]);
display.display();
}
// -------------------------------------------------------
// Screensaver dispatcher
// -------------------------------------------------------
void drawScreensaver() {
if (!displayOK || !displayEnabled) return;
switch (SCREENSAVER_MODE) {
case SS_SCROLL_BANNER: drawScrollBanner(); break;
}
}
// -------------------------------------------------------
// Screensaver: scrolling banner
// -------------------------------------------------------
void drawScrollBanner() {
if (!displayOK) return;
if (millis() - lastScrollTick < SCROLL_TICK_MS) return;
lastScrollTick = millis();
if (currentBanner == -1) pickNextBanner();
display.clearDisplay();
display.setTextWrap(false);
display.setTextSize(BANNER_SIZE);
display.setTextColor(SSD1306_WHITE);
int16_t yPos = (32 - (7 * BANNER_SIZE)) / 2;
display.setCursor(scrollX, yPos);
if (currentBanner == -2) {
display.print(BATTERY_WARNING_TEXT);
} else {
display.print(BANNER_TEXTS[currentBanner]);
}
display.print(" ");
display.display();
scrollX -= 2;
if (scrollX <= -textWidth) {
pickNextBanner();
scrollX = 128;
}
}
// -------------------------------------------------------
// Button debounce — fires on release, only if held past lockout
// -------------------------------------------------------
bool readButton(int pin, const char* label, bool &lastPin, uint32_t &lockUntil) {
bool current = digitalRead(pin);
if (current == LOW && lastPin == HIGH) {
lockUntil = millis() + DEBOUNCE_MS;
lastPin = LOW;
} else if (current == HIGH && lastPin == LOW) {
lastPin = HIGH;
if (millis() >= lockUntil) return true;
}
return false;
}
void resetAnimationState() {
breatheBrightness = BREATHE_MIN;
breatheStep = BREATHE_STEP;
lastBreatheStep = millis();
strobeOn = false;
lastStrobeTime = millis();
rainbowHue = 0;
lastRainbowStep = millis();
discoColour = -1;
lastDiscoColour = -1;
lastDiscoTick = millis();
fireBrightness = 1.0;
fireColourIndex = 0;
lastFireTick = millis();
policeState = 0;
policeFlashCount = 0;
policeFlashOn = false;
lastPoliceTick = millis();
pwmDirty = true;
}
// -------------------------------------------------------
void setup() {
Serial.begin(115200);
pinMode(PIN_RED, OUTPUT);
pinMode(PIN_GREEN, OUTPUT);
pinMode(PIN_BLUE, OUTPUT);
pinMode(PIN_BTN_COLOUR, INPUT_PULLUP);
pinMode(PIN_BTN_MODE, INPUT_PULLUP);
pinMode(PIN_BTN_DISPLAY, INPUT_PULLUP);
randomSeed(analogRead(A3));
Wire.begin();
displayOK = display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
Serial.println("Boot: OK");
if (!displayOK) {
Serial.println(F("SSD1306 allocation failed"));
for(;;);
}
display.clearDisplay();
display.display();
resetAnimationState();
lastActivityTime = millis();
lastBatteryCheck = millis();
drawStatus();
}
// -------------------------------------------------------
void loop() {
uint32_t now = millis();
bool activityDetected = false;
// ---- LED rendering — first in loop to ensure analogWrite completes
// before any I2C display transactions, preventing PWM glitches ----
uint8_t targetR = 0, targetG = 0, targetB = 0;
if (currentColour == RAINBOW_INDEX) {
if (now - lastRainbowStep >= RAINBOW_STEP_MS) {
lastRainbowStep = now;
rainbowHue = (rainbowHue + 1) % 360;
pwmDirty = true;
}
hsvToRgb((float)rainbowHue, 1.0, 1.0, targetR, targetG, targetB);
} else {
targetR = COLOURS[currentColour][0];
targetG = COLOURS[currentColour][1];
targetB = COLOURS[currentColour][2];
}
switch (currentMode) {
case 0: // Static
setColour(targetR, targetG, targetB);
break;
case 1: // Breathe
if (now - lastBreatheStep >= BREATHE_TICK) {
lastBreatheStep = now;
breatheBrightness += breatheStep;
if (breatheBrightness >= BREATHE_MAX || breatheBrightness <= BREATHE_MIN) {
breatheStep = -breatheStep;
}
pwmDirty = true;
}
setColour((uint8_t)(targetR * breatheBrightness),
(uint8_t)(targetG * breatheBrightness),
(uint8_t)(targetB * breatheBrightness));
break;
case 2: // Strobe
if (now - lastStrobeTime >= (strobeOn ? STROBE_ON_MS : STROBE_OFF_MS)) {
lastStrobeTime = now;
strobeOn = !strobeOn;
pwmDirty = true;
}
if (strobeOn) setColour(targetR, targetG, targetB);
else ledsOff();
break;
case 3: { // Disco
if (discoColour == -1 || now - lastDiscoTick >= DISCO_TICK_MS) {
lastDiscoTick = now;
int8_t pick;
do {
pick = (int8_t)random(NUM_COLOURS - 1);
} while (pick == lastDiscoColour);
lastDiscoColour = pick;
discoColour = pick;
pwmDirty = true;
}
setColour(COLOURS[discoColour][0],
COLOURS[discoColour][1],
COLOURS[discoColour][2]);
break;
}
case 4: { // Fire
// Each tick: pick a random fire colour and a random brightness.
// Brightness is constrained to FIRE_BRIGHTNESS_MIN–1.0 so the
// flame never goes fully dark. Colour shifts randomly between
// deep red, orange, amber and yellow to simulate natural flicker.
if (now - lastFireTick >= FIRE_TICK_MS) {
lastFireTick = now;
fireColourIndex = (uint8_t)random(NUM_FIRE_COLOURS);
fireBrightness = FIRE_BRIGHTNESS_MIN +
(float)random(100) / 100.0 * (1.0 - FIRE_BRIGHTNESS_MIN);
pwmDirty = true;
}
setColour(
(uint8_t)(FIRE_COLOURS[fireColourIndex][0] * fireBrightness),
(uint8_t)(FIRE_COLOURS[fireColourIndex][1] * fireBrightness),
(uint8_t)(FIRE_COLOURS[fireColourIndex][2] * fireBrightness)
);
break;
}
case 5: { // Police
// State machine: red burst → gap → blue burst → gap → repeat.
// Flash states (0, 2) are gated by POLICE_FLASH_MS.
// Gap states (1, 3) are gated by POLICE_GAP_MS.
// Each has its own timer check so they don't interfere.
switch (policeState) {
case 0: // red burst — flash on/off POLICE_BURST_COUNT times
if (now - lastPoliceTick >= POLICE_FLASH_MS) {
lastPoliceTick = now;
policeFlashOn = !policeFlashOn;
if (policeFlashOn) {
setColour(255, 0, 0);
} else {
ledsOff();
policeFlashCount++;
if (policeFlashCount >= POLICE_BURST_COUNT) {
policeFlashCount = 0;
policeFlashOn = false;
policeState = 1;
lastPoliceTick = now; // start gap timer
}
}
pwmDirty = true;
}
break;
case 1: // gap after red burst
ledsOff();
if (now - lastPoliceTick >= POLICE_GAP_MS) {
policeState = 2;
lastPoliceTick = now; // start blue burst timer
}
break;
case 2: // blue burst — flash on/off POLICE_BURST_COUNT times
if (now - lastPoliceTick >= POLICE_FLASH_MS) {
lastPoliceTick = now;
policeFlashOn = !policeFlashOn;
if (policeFlashOn) {
setColour(0, 0, 255);
} else {
ledsOff();
policeFlashCount++;
if (policeFlashCount >= POLICE_BURST_COUNT) {
policeFlashCount = 0;
policeFlashOn = false;
policeState = 3;
lastPoliceTick = now; // start gap timer
}
}
pwmDirty = true;
}
break;
case 3: // gap after blue burst
ledsOff();
if (now - lastPoliceTick >= POLICE_GAP_MS) {
policeState = 0;
lastPoliceTick = now; // start red burst timer
}
break;
}
break;
}
}
// ---- Buttons ----
if (readButton(PIN_BTN_COLOUR, "Colour", lastColourPin, colourLockUntil)) {
currentColour = (currentColour + 1) % NUM_COLOURS;
Serial.print("-Event: COLOUR CHANGE "); Serial.println(COLOUR_NAMES[currentColour]);
activityDetected = true;
pwmDirty = true;
}
if (readButton(PIN_BTN_MODE, "Mode", lastModePin, modeLockUntil)) {
currentMode = (currentMode + 1) % NUM_MODES;
Serial.print("-Event: MODE CHANGE "); Serial.println(MODE_NAMES[currentMode]);
activityDetected = true;
resetAnimationState();
}
if (readButton(PIN_BTN_DISPLAY, "Display", lastDisplayPin, displayLockUntil)) {
displayEnabled = !displayEnabled;
if (displayEnabled) {
display.ssd1306_command(SSD1306_DISPLAYON);
drawStatus();
lastActivityTime = now;
screensaverActive = false;
currentBanner = -1;
scrollX = 128;
Serial.println("-Event: DISPLAY ON");
} else {
display.clearDisplay();
display.display();
display.ssd1306_command(SSD1306_DISPLAYOFF);
Serial.println("-Event: DISPLAY OFF");
}
}
if (activityDetected) {
lastActivityTime = now;
if (screensaverActive) {
screensaverActive = false;
currentBanner = -1;
scrollX = 128;
}
drawStatus();
} else if (!screensaverActive && (now - lastActivityTime >= SCREENSAVER_MS)) {
screensaverActive = true;
currentBanner = -1;
scrollX = 128;
}
if (screensaverActive) drawScreensaver();
// ---- Battery monitoring ----
float vccResult = 0.0;
if (vccState == VCC_IDLE && now - lastBatteryCheck >= BATTERY_CHECK_MS) {
lastBatteryCheck = now;
readVCCNonBlocking(vccResult);
}
if (readVCCNonBlocking(vccResult)) {
bool wasLow = batteryLow;
batteryLow = (vccResult < BATTERY_LOW_V);
if (batteryLow && !wasLow) {
Serial.print("WARNING: battery low (");
Serial.print(vccResult);
Serial.println("V)");
drawStatus();
}
}
}
Colour Cycle
Mode Step
Display Toggle