ESP32 Beeminder Urgency Display

I built/programmed a WiFi-connected LED matrix that monitors my Beeminder goals and provides escalating visual alerts throughout the day. Code and post mostly written by Claude. :wink:

:bullseye: The Concept

The display shows the number of urgent goals (derailing within 24 hours) as a red digit on an 8Ɨ8 LED matrix. The genius is in the time-based escalation:

Morning to Evening (7 AM - 7 PM)

  • Displays a solid red number showing urgent goal count
  • Brightness increases exponentially from barely visible (5) to full brightness (30)
  • Creates gentle but growing awareness throughout the day

Evening Escalation (7 PM - Midnight)

  • Switches to blinking the urgent goal number
  • Blink frequency increases linearly from slow (2 seconds) to frantic (200ms)
  • Maximum psychological pressure right when you need to act!

Sleep Mode (Midnight - 7 AM)

  • Display turns off completely
  • No distractions during sleep hours

No Urgent Goals

  • Display stays off

:hammer_and_wrench: Hardware Setup

Single component needed:

That’s it! The ESP32-S3-Matrix has everything built-in:

  • ESP32-S3 microcontroller
  • 8Ɨ8 WS2812B RGB LED matrix
  • All wiring done for you
  • Compact all-in-one design

Zero assembly required - just plug in USB-C and start coding!

:mobile_phone: Software Features

  • Beeminder API Integration - Checks goals every 20 minutes
  • NTP Time Sync - Accurate time-based escalation
  • WiFi Auto-Reconnect - Reliable operation
  • Customizable Time Zones - Works anywhere
  • Visual Goal Counter - Shows 1-9 urgent goals clearly

:brain: The Psychology

This hits the perfect sweet spot between helpful and annoying:

  1. Morning awareness without being distracting
  2. Gradual escalation that builds urgency naturally
  3. Maximum pressure during prime ā€œdo the thingā€ hours
  4. Respectful sleep with no late-night harassment

:wrench: Setup Process

  1. Get your Beeminder auth token from Beeminder API Reference
  2. Flash the Arduino code to your ESP32-S3-Matrix via USB-C (I can share the full code)
  3. Configure your WiFi credentials and Beeminder username in the code
  4. Set your timezone and preferred escalation hours
  5. Mount somewhere visible - I put mine on my desk

The whole setup took me about 30 minutes including coding time. No soldering, no wiring, no breadboards (Although I did initially set it all up using a breadboard and a simple led diode until I found out about the esp32 chip with integrated led matrix…).

7 Likes

Great stuff. I think you have fewer goals than me!

PS: envoyƩ du donjon du Louvre, en Ʃchappant de la chaleur.

2 Likes

Wow, really cool! It’s very small, so cute!
I wonder if there is a way to put a small battery and a casing to put it on a desk…

3 Likes

Nice work! I’ll take you up on the offer to share the code.

What’s the highest number it can display?

1 Like

Sure no problem, lets see if I can embed it in a post here :slight_smile:

Currently highest number is 9 which is more than enough for me, but should be no problem to either have it show something like 9+ for more or even a scrolling number once you need more than 1 digit… Or if you really have many you could use one led per task which would get you up to 64. That would let you encode each tasks status as a color as well :thinking:

Or you just go for a proper lcd/e-ink display (like this) directly.

Code
/*
 * ESP32-S3 RGB Matrix Beeminder Status Display (Time-Based Escalation)
 * Monitors Beeminder goals and displays escalating status via 8x8 RGB LED matrix
 * Escalates brightness during day, then blinking frequency in evening
 */

#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#include <FastLED.h>

// Configuration - MODIFY THESE VALUES
const char* WIFI_SSID = "";
const char* WIFI_PASSWORD = "";
const char* BEEMINDER_USERNAME = "";
const char* BEEMINDER_AUTH_TOKEN = "";

// NTP Server for time synchronization
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 1*60*60;     // Adjust for your timezone (0 = UTC)
const int daylightOffset_sec = 1*60*60; // Adjust for daylight saving

// Time-based escalation settings
const int BRIGHTNESS_START_HOUR = 7;   // 7 AM - start brightness escalation
const int ESCALATION_START_HOUR = 19;  // 7 PM - start blinking escalation
const int MAX_ESCALATION_HOUR = 24;    // Midnight - maximum blinking
const int SLEEP_START = 0;
const int SLEEP_END = 7;

// Brightness settings
const int MIN_BRIGHTNESS = 1;
const int MAX_BRIGHTNESS = 10;

// RGB Matrix configuration for Waveshare ESP32-S3-Matrix board
#define LED_PIN 14       // ESP32-S3-Matrix uses GPIO14 for the built-in 8x8 RGB matrix
#define NUM_LEDS 64      // 8x8 = 64 LEDs
#define LED_TYPE WS2812B // Board uses WS2812B RGB LEDs
#define COLOR_ORDER RGB  // Changed from GRB to RGB - board has different color order

CRGB leds[NUM_LEDS];

// Timing constants
const unsigned long CHECK_INTERVAL = 1 * 60 * 1000; // 20 minutes in milliseconds

// Global variables
unsigned long lastBeeminderCheck = 0;
unsigned long lastAnimation = 0;
int urgentGoals = 0;
int displayMode = 0; // 0=off, 1=solid_number, 2=blinking_number
unsigned long animationInterval = 1000;
int animationStep = 0;
int currentBrightness = MIN_BRIGHTNESS;

// Color definitions
CRGB COLOR_OFF = CRGB::Black;
CRGB COLOR_URGENT = CRGB::Red;

void setup() {
  Serial.begin(115200);
  delay(1000);
  
  Serial.println("Starting ESP32-S3 RGB Matrix Beeminder Status Display...");
  
  // Initialize FastLED
  FastLED.addLeds<LED_TYPE, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS).setCorrection(TypicalLEDStrip);
  FastLED.setBrightness(MIN_BRIGHTNESS);
  
  // Test the matrix
  testMatrix();
  
  // Connect to WiFi
  connectToWiFi();
  
  // Initialize time
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
  Serial.println("Getting time from NTP server...");
  delay(2000); // Give time to sync
  
  // Initial Beeminder check
  checkBeeminderGoals();
  
  Serial.println("Setup complete!");
}

void loop() {
  unsigned long currentTime = millis();
  
  // Check Beeminder goals periodically
  if (currentTime - lastBeeminderCheck >= CHECK_INTERVAL) {
    if (WiFi.status() == WL_CONNECTED) {
      checkBeeminderGoals();
    } else {
      Serial.println("WiFi disconnected, attempting reconnection...");
      connectToWiFi();
    }
    lastBeeminderCheck = currentTime;
  }
  
  // Handle LED animations
  handleMatrixDisplay();
  
  // Small delay to prevent excessive CPU usage
  delay(50);
}

void testMatrix() {
  Serial.println("Testing RGB Matrix...");
  
  // Fill with red
  fill_solid(leds, NUM_LEDS, CRGB::Red);
  FastLED.show();
  delay(500);
  
  // Fill with green  
  fill_solid(leds, NUM_LEDS, CRGB::Green);
  FastLED.show();
  delay(500);
  
  // Fill with blue
  fill_solid(leds, NUM_LEDS, CRGB::Blue);
  FastLED.show();
  delay(500);
  
  // Clear
  FastLED.clear();
  FastLED.show();
  
  Serial.println("Matrix test complete!");
}

void connectToWiFi() {
  Serial.println("Connecting to WiFi...");
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  
  int attempts = 0;
  while (WiFi.status() != WL_CONNECTED && attempts < 20) {
    delay(500);
    Serial.print(".");
    attempts++;
  }
  
  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("");
    Serial.println("WiFi connected!");
    Serial.print("IP address: ");
    Serial.println(WiFi.localIP());
  } else {
    Serial.println("");
    Serial.println("WiFi connection failed!");
  }
}



void checkBeeminderGoals() {
  Serial.println("Checking Beeminder goals...");
  
  HTTPClient http;
  String url = String("https://www.beeminder.com/api/v1/users/") + 
               BEEMINDER_USERNAME + "/goals.json?auth_token=" + BEEMINDER_AUTH_TOKEN;
  
  http.begin(url);
  http.addHeader("User-Agent", "ESP32-S3-RGB-Matrix-Beeminder/1.0");
  
  int httpResponseCode = http.GET();
  
  if (httpResponseCode == 200) {
    String payload = http.getString();
    parseBeeminderResponse(payload);
  } else {
    Serial.print("HTTP Error: ");
    Serial.println(httpResponseCode);
    Serial.println("Response: " + http.getString());
    
    // Keep display off on error
    setDisplayMode(0);
  }
  
  http.end();
}

void parseBeeminderResponse(String jsonResponse) {
  DynamicJsonDocument doc(8192);
  DeserializationError error = deserializeJson(doc, jsonResponse);
  
  if (error) {
    Serial.print("JSON parsing failed: ");
    Serial.println(error.c_str());
    setDisplayMode(0); // Turn off on error
    return;
  }
  
  urgentGoals = 0;
  int totalGoals = 0;
  
  // Get current time using time library
  time_t now;
  time(&now);
  
  for (JsonObject goal : doc.as<JsonArray>()) {
    String slug = goal["slug"];
    long losedate = goal["losedate"];
    bool queued = goal["queued"];
    
    totalGoals++;
    
    // Skip queued goals
    if (queued) {
      continue;
    }
    
    // Check if goal is urgent (derails within 24 hours)
    long timeUntilDerail = losedate - now;
    if (timeUntilDerail <= 86400) { // 24 hours in seconds
      urgentGoals++;
      Serial.print("URGENT: ");
      Serial.print(slug);
      Serial.print(" derails in ");
      Serial.print(timeUntilDerail / 3600);
      Serial.println(" hours");
    }
  }
  
  Serial.print("Total goals: ");
  Serial.println(totalGoals);
  Serial.print("Urgent goals: ");
  Serial.println(urgentGoals);
  
  // Set display pattern based on urgency and time
  updateDisplayBasedOnTimeAndUrgency();
}

void updateDisplayBasedOnTimeAndUrgency() {
  if (urgentGoals == 0) {
    setDisplayMode(0); // Off - no urgent goals
    return;
  }
  
  // Get current hour
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) {
    Serial.println("Failed to obtain time");
    setDisplayMode(1); // Default to solid display if time unavailable
    return;
  }
  
  int currentHour = timeinfo.tm_hour;
  int currentMinute = timeinfo.tm_min;
  Serial.print("Current time: ");
  Serial.print(currentHour);
  Serial.print(":");
  Serial.println(currentMinute);
  
  if (SLEEP_START <= currentHour && currentHour < SLEEP_END) {
    setDisplayMode(0);
    Serial.println("Sleep time - display off");
    return;
  }
  
  if (currentHour >= BRIGHTNESS_START_HOUR && currentHour < ESCALATION_START_HOUR) {
    // Brightness escalation period (7 AM to 7 PM)
    calculateBrightness(currentHour, currentMinute);
    setDisplayMode(1); // Solid number display
    animationInterval = 0; // No animation, just solid display
    Serial.print("Brightness escalation period - brightness: ");
    Serial.println(currentBrightness);
    
  } else if (currentHour >= ESCALATION_START_HOUR) {
    // Blinking escalation period (7 PM to midnight)
    currentBrightness = MAX_BRIGHTNESS; // Max brightness during blinking period
    FastLED.setBrightness(currentBrightness);
    
    setDisplayMode(2); // Blinking number display
    calculateBlinkingSpeed(currentHour, currentMinute);
    Serial.print("Blinking escalation period - interval: ");
    Serial.print(animationInterval);
    Serial.println("ms");
    
  } else {
    // Between midnight and 7 AM - display off
    setDisplayMode(0);
    Serial.println("Night time - display off");
  }
}

void calculateBrightness(int hour, int minute) {
  // Calculate time progress from BRIGHTNESS_START_HOUR to ESCALATION_START_HOUR
  float totalMinutes = (ESCALATION_START_HOUR - BRIGHTNESS_START_HOUR) * 60.0;
  float currentMinutes = (hour - BRIGHTNESS_START_HOUR) * 60.0 + minute;
  float progress = currentMinutes / totalMinutes;
  
  if (progress > 1.0) progress = 1.0;
  if (progress < 0.0) progress = 0.0;
  
  // Exponential brightness increase: y = MIN + (MAX-MIN) * progress^2
  currentBrightness = MIN_BRIGHTNESS + (MAX_BRIGHTNESS - MIN_BRIGHTNESS) * progress * progress;
  
  if (currentBrightness > MAX_BRIGHTNESS) currentBrightness = MAX_BRIGHTNESS;
  if (currentBrightness < MIN_BRIGHTNESS) currentBrightness = MIN_BRIGHTNESS;
  
  FastLED.setBrightness(currentBrightness);
}

void calculateBlinkingSpeed(int hour, int minute) {
  // Calculate time progress from ESCALATION_START_HOUR to MAX_ESCALATION_HOUR
  float totalMinutes = (MAX_ESCALATION_HOUR - ESCALATION_START_HOUR) * 60.0;
  float currentMinutes = (hour - ESCALATION_START_HOUR) * 60.0 + minute;
  float progress = currentMinutes / totalMinutes;
  
  if (progress > 1.0) progress = 1.0;
  if (progress < 0.0) progress = 0.0;
  
  // Linear increase in blinking frequency
  // Start with slow blink (2 seconds), end with fast blink (200ms)
  int maxInterval = 2000; // 2 seconds
  int minInterval = 200;  // 200ms
  
  animationInterval = maxInterval - (maxInterval - minInterval) * progress;
}

void setDisplayMode(int mode) {
  displayMode = mode;
  animationStep = 0; // Reset animation
  Serial.print("Display mode set to: ");
  switch (mode) {
    case 0: Serial.println("Off"); break;
    case 1: Serial.println("Solid number"); break;
    case 2: Serial.println("Blinking number"); break;
  }
}

void handleMatrixDisplay() {
  unsigned long currentTime = millis();
  
  // Handle solid display (no animation timing needed)
  if (displayMode == 1) {
    displaySolidNumber();
    FastLED.show();
    return;
  }
  
  // Handle animated displays
  if (displayMode == 2 && currentTime - lastAnimation >= animationInterval) {
    lastAnimation = currentTime;
    displayBlinkingNumber();
    FastLED.show();
    return;
  }
  
  // Handle off state
  if (displayMode == 0) {
    displayOff();
    FastLED.show();
    return;
  }
}

void displayOff() {
  FastLED.clear();
}

void displaySolidNumber() {
  FastLED.clear();
  
  if (urgentGoals == 0) {
    return;
  }
  
  int numberToShow = min(urgentGoals, 9); // Cap at 9
  displayNumber(numberToShow, COLOR_URGENT);
}

void displayBlinkingNumber() {
  if (animationStep % 2 == 0) {
    // Show number
    displaySolidNumber();
  } else {
    // Hide number
    FastLED.clear();
  }
  
  animationStep++;
}

// Utility function to get LED index from row/col
int getIndex(int row, int col) {
  if (row < 0 || row >= 8 || col < 0 || col >= 8) {
    return -1; // Invalid position
  }
  return row * 8 + col;
}

// Display a number (0-9) on the 8x8 matrix
void displayNumber(int number, CRGB color) {
  // 8x8 digit patterns - each number is defined as a 2D array
  // 1 = LED on, 0 = LED off
  
  bool patterns[10][8][8] = {
    // 0
    {
      {0,0,1,1,1,1,0,0},
      {0,1,1,0,0,1,1,0},
      {0,1,0,0,0,0,1,0},
      {0,1,0,0,0,0,1,0},
      {0,1,0,0,0,0,1,0},
      {0,1,0,0,0,0,1,0},
      {0,1,1,0,0,1,1,0},
      {0,0,1,1,1,1,0,0}
    },
    // 1
    {
      {0,0,0,1,1,0,0,0},
      {0,0,1,1,1,0,0,0},
      {0,0,0,1,1,0,0,0},
      {0,0,0,1,1,0,0,0},
      {0,0,0,1,1,0,0,0},
      {0,0,0,1,1,0,0,0},
      {0,0,0,1,1,0,0,0},
      {0,1,1,1,1,1,1,0}
    },
    // 2
    {
      {0,0,1,1,1,1,0,0},
      {0,1,1,0,0,1,1,0},
      {0,0,0,0,0,1,1,0},
      {0,0,0,0,1,1,0,0},
      {0,0,0,1,1,0,0,0},
      {0,0,1,1,0,0,0,0},
      {0,1,1,0,0,0,0,0},
      {0,1,1,1,1,1,1,0}
    },
    // 3
    {
      {0,0,1,1,1,1,0,0},
      {0,1,1,0,0,1,1,0},
      {0,0,0,0,0,1,1,0},
      {0,0,0,1,1,1,0,0},
      {0,0,0,0,0,1,1,0},
      {0,0,0,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,0,1,1,1,1,0,0}
    },
    // 4
    {
      {0,0,0,0,1,1,0,0},
      {0,0,0,1,1,1,0,0},
      {0,0,1,1,1,1,0,0},
      {0,1,1,0,1,1,0,0},
      {0,1,1,1,1,1,1,0},
      {0,0,0,0,1,1,0,0},
      {0,0,0,0,1,1,0,0},
      {0,0,0,0,1,1,0,0}
    },
    // 5
    {
      {0,1,1,1,1,1,1,0},
      {0,1,1,0,0,0,0,0},
      {0,1,1,0,0,0,0,0},
      {0,1,1,1,1,1,0,0},
      {0,0,0,0,0,1,1,0},
      {0,0,0,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,0,1,1,1,1,0,0}
    },
    // 6
    {
      {0,0,1,1,1,1,0,0},
      {0,1,1,0,0,1,1,0},
      {0,1,1,0,0,0,0,0},
      {0,1,1,1,1,1,0,0},
      {0,1,1,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,0,1,1,1,1,0,0}
    },
    // 7
    {
      {0,1,1,1,1,1,1,0},
      {0,0,0,0,0,1,1,0},
      {0,0,0,0,1,1,0,0},
      {0,0,0,0,1,1,0,0},
      {0,0,0,1,1,0,0,0},
      {0,0,0,1,1,0,0,0},
      {0,0,1,1,0,0,0,0},
      {0,0,1,1,0,0,0,0}
    },
    // 8
    {
      {0,0,1,1,1,1,0,0},
      {0,1,1,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,0,1,1,1,1,0,0},
      {0,1,1,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,0,1,1,1,1,0,0}
    },
    // 9
    {
      {0,0,1,1,1,1,0,0},
      {0,1,1,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,0,1,1,1,1,1,0},
      {0,0,0,0,0,1,1,0},
      {0,1,1,0,0,1,1,0},
      {0,0,1,1,1,1,0,0}
    }
  };
  
  // Display the pattern
  if (number >= 0 && number <= 9) {
    for (int row = 0; row < 8; row++) {
      for (int col = 0; col < 8; col++) {
        if (patterns[number][row][col] == 1) {
          int index = getIndex(row, col);
          if (index >= 0) {
            leds[index] = color;
          }
        }
      }
    }
  }
}
1 Like

Thanks, yes its super small!

I think that could be done, although you’d want to optimize the code to be more efficient than it is currently (in particular I think you can tell the esp32 chip to sleep in between operations which I’m not doing currently). I just have it dangling by a usb cable which works fine for me right now :slight_smile:

2 Likes