From 3af958b69f050ceac8cfd8e843658eb575483d13 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 29 May 2026 20:17:47 -0700 Subject: [PATCH] feat: add Arduino Uno R4 WiFi board - LED matrix doorbell display with scrolling text - Non-blocking HTTP poll state machine for ntfy.sh - Ticker tape pressure gauge (12 cols, 3-min window, PWM decay) - Unix timestamp message filtering (readyTime after NTP sync) - BUILTIN LED sine-wave brightness heartbeat - Pending: remove backup file before merge --- boards/uno-r4-wifi/README.md | 111 ++++++ boards/uno-r4-wifi/board-config.sh | 4 + boards/uno-r4-wifi/test_messages.sh | 51 +++ boards/uno-r4-wifi/uno-r4-wifi.ino | 563 ++++++++++++++++++++++++++++ 4 files changed, 729 insertions(+) create mode 100644 boards/uno-r4-wifi/README.md create mode 100644 boards/uno-r4-wifi/board-config.sh create mode 100755 boards/uno-r4-wifi/test_messages.sh create mode 100644 boards/uno-r4-wifi/uno-r4-wifi.ino diff --git a/boards/uno-r4-wifi/README.md b/boards/uno-r4-wifi/README.md new file mode 100644 index 0000000..c6301c2 --- /dev/null +++ b/boards/uno-r4-wifi/README.md @@ -0,0 +1,111 @@ +# Uno R4 WiFi Doorbell - Quick Start Guide + +## Overview +This is a simplified doorbell notification system for the Arduino Uno R4 WiFi that displays incoming ntfy.sh messages on the built-in 12x8 LED matrix. + +## Features +- Connects to WiFi and monitors a ntfy.sh topic +- Displays incoming messages as scrolling text on LED matrix +- Scrolls at 2 columns per second (configurable) +- Auto-silences after 60 seconds of display +- Simple state machine: CONNECTING → IDLE → DISPLAYING → SILENCED + +## Setup Instructions + +### 1. Configure WiFi Credentials +Edit `boards/uno-r4-wifi/uno-r4-wifi.ino`: + +```cpp +#define WIFI_SSID "YOUR_WIFI_SSID" +#define WIFI_PASS "YOUR_WIFI_PASSWORD" +``` + +### 2. Configure ntfy.sh Topic +Edit `boards/uno-r4-wifi/uno-r4-wifi.ino`: + +```cpp +#define NTFY_TOPIC "YOUR_TOPIC_HERE" +``` + +Create a free topic at https://ntfy.sh/ (e.g., "my-doorbell-12345") + +### 3. Upload the Sketch +```bash +arduino-cli compile --fqbn arduino:renesas_uno:unor4wifi boards/uno-r4-wifi/uno-r4-wifi.ino +arduino-cli upload --fqbn arduino:renesas_uno:unor4wifi -p /dev/ttyACM0 boards/uno-r4-wifi/uno-r4-wifi.ino +``` + +### 4. Test the System +Open a terminal and run the test script: +```bash +chmod +x boards/uno-r4-wifi/test_messages.sh +./boards/uno-r4-wifi/test_messages.sh +``` + +Or manually send a message: +```bash +curl -X POST https://ntfy.sh/YOUR_TOPIC_HERE -d "Hello from ntfy!" +``` + +## How It Works + +### State Machine +1. **CONNECTING_WIFI** - Attempts to connect to WiFi +2. **IDLE** - Polls ntfy.sh every 5 seconds for new messages +3. **DISPLAYING_MESSAGE** - Shows scrolling text for 60 seconds +4. **SILENCED** - Clears matrix for 5 seconds before returning to IDLE + +### LED Matrix Display +- Text scrolls from right to left across the 12x8 matrix +- Scroll rate: 2 columns per second (500ms per column) +- Characters are 5 pixels wide with 1 pixel spacing +- Only alphanumeric characters (A-Z, 0-9) and spaces are supported + +### ntfy.sh Integration +- Uses HTTP GET with polling (`/json?poll=1`) +- Tracks last message ID to avoid duplicates +- Parses JSON response for message body + +## Pinout Reference (Uno R4 WiFi) +- **D0-D13**: Standard digital pins +- **A0-A5**: Analog inputs +- **D26/D27**: Qwiic (I2C) connector (SCL/SDA) +- **LED Matrix**: Built-in 12x8 matrix (pins handled internally) + +## Customization + +### Adjust Timing +Edit constants in `uno-r4-wifi.ino`: +```cpp +#define POLL_INTERVAL_MS 5000 // How often to check for messages +#define DISPLAY_DURATION_MS 60000 // How long to display each message +#define SCROLL_SPEED_MS 500 // Scroll speed (lower = faster) +#define SILENCE_DURATION_MS 5000 // Time between messages +``` + +### Add More Characters +The font array in `drawCharToFrame()` can be extended to support additional characters. + +## Troubleshooting + +### WiFi Won't Connect +- Check WiFi credentials +- Ensure 2.4GHz network (Uno R4 WiFi doesn't support 5GHz) +- Verify WiFi signal strength + +### LED Matrix Not Showing Text +- Ensure `matrix.begin()` is called in setup() +- Check that `matrix.loadFrame()` is called with valid data +- Verify text contains only supported characters + +### No Messages Received +- Check ntfy.sh topic name matches exactly +- Ensure the topic is publicly accessible +- Test with curl command manually +- Check serial monitor for error messages + +## Next Steps +- Add support for more characters in the font +- Implement different message types (Alert, Silence, etc.) +- Add color coding with different LED patterns +- Integrate with the main doorbell codebase diff --git a/boards/uno-r4-wifi/board-config.sh b/boards/uno-r4-wifi/board-config.sh new file mode 100644 index 0000000..818f5b3 --- /dev/null +++ b/boards/uno-r4-wifi/board-config.sh @@ -0,0 +1,4 @@ +FQBN=arduino:renesas_uno:unor4wifi +PORT=/dev/ttyACM0 +LIBS="" +OPTS="-DDEBUG_MODE" \ No newline at end of file diff --git a/boards/uno-r4-wifi/test_messages.sh b/boards/uno-r4-wifi/test_messages.sh new file mode 100755 index 0000000..4c51557 --- /dev/null +++ b/boards/uno-r4-wifi/test_messages.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Test script for Uno R4 WiFi doorbell +# Sends test messages to ntfy.sh + +# Update this with your actual topic +NTFY_TOPIC="ALERT_klubhaus_topic_test" + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}Uno R4 WiFi Doorbell Test Script${NC}" +echo "======================================" + +echo -e "Sending test messages to: https://ntfy.sh/$NTFY_TOPIC" +echo "" + +# Send different test messages +echo "1. Sending simple alert..." +curl -s -X POST "https://ntfy.sh/$NTFY_TOPIC" \ + -d "Doorbell pressed!" \ + -H "Title: ALERT" \ + -H "Priority: high" + +sleep 2 + +echo -e "\n2. Sending longer message..." +curl -s -X POST "https://ntfy.sh/$NTFY_TOPIC" \ + -d "Front door visitor detected at main entrance" \ + -H "Title: ALERT" \ + -H "Priority: high" + +sleep 2 + +echo -e "\n3. Sending test with special characters..." +curl -s -X POST "https://ntfy.sh/$NTFY_TOPIC" \ + -d "Package delivered! #12345" \ + -H "Title: ALERT" \ + -H "Priority: high" + +sleep 2 + +echo -e "\n4. Sending numeric message..." +curl -s -X POST "https://ntfy.sh/$NTFY_TOPIC" \ + -d "Temperature: 72F" \ + -H "Title: ALERT" \ + -H "Priority: high" + +echo -e "\n${GREEN}Done! Check the LED matrix on your Uno R4 WiFi.${NC}" diff --git a/boards/uno-r4-wifi/uno-r4-wifi.ino b/boards/uno-r4-wifi/uno-r4-wifi.ino new file mode 100644 index 0000000..9348cfc --- /dev/null +++ b/boards/uno-r4-wifi/uno-r4-wifi.ino @@ -0,0 +1,563 @@ +// +// Klubhaus Doorbell - Uno R4 WiFi Edition +// Monitors ntfy.sh alert topic and displays messages on LED matrix +// + +#include "Arduino_LED_Matrix.h" +#include "WiFiS3.h" +#include "ArduinoJson.h" +#include "RTC.h" // Add RTC library for time synchronization + +// WiFi credentials +#define WIFI_SSID "iot-2GHz" +#define WIFI_PASS "lesson-greater" + +// ntfy.sh topic +#define NTFY_TOPIC "ALERT_klubhaus_topic_test" + +// Timing constants +#define POLL_INTERVAL_MS 5000 // Poll ntfy.sh every 5 seconds +#define DISPLAY_DURATION_MS 60000 // Show message for 60 seconds +#define SCROLL_SPEED_MS 100 // Scroll speed (lower = faster) - 4 columns/sec +#define SILENCE_DURATION_MS 5000 // Stay silenced for 5 seconds +#define GAUGE_MAX_AGE_MS 180000 // 3-minute window for gauge +#define GAUGE_BUCKET_SEC 15 // 15 seconds per column +#define MAX_GAUGE_ENTRIES 12 // Max message timestamps + +long gaugeTimestamps[MAX_GAUGE_ENTRIES] = {0}; +int gaugeCount = 0; + +// LED matrix dimensions +#define MATRIX_COLS 12 +#define MATRIX_ROWS 8 + +// Global objects +ArduinoLEDMatrix matrix; +WiFiClient client; + +// State machine +enum AppState { + STATE_IDLE, + STATE_DISPLAYING_MESSAGE +}; +AppState currentState = STATE_IDLE; + +// Message state +String currentMessage = ""; +String lastDisplayedMessage = ""; // Track last displayed message to avoid duplicates +String lastMessageId = ""; // Track last message ID for filtering +unsigned long messageStartTime = 0; +unsigned long lastScrollTime = 0; +unsigned long lastPollTime = 0; +unsigned long bootTime = 0; +unsigned long bootDelayEndTime = 0; // When to start processing messages +unsigned long lastDisplayedMessageTime = 0; // Unix timestamp of last displayed message +long currentMessageTime = 0; // Unix timestamp of current message being displayed + +// Non-blocking poll state machine +enum PollState { POLL_IDLE, POLL_WAIT, POLL_CONNECT, POLL_SEND, POLL_READ, POLL_PARSE }; +PollState pollState = POLL_IDLE; +unsigned long pollStepTime = 0; +String pollResponse = ""; +long readyTime = 0; // Unix timestamp when device became ready (after NTP sync) +int queuedMessageCount = 0; +bool rtcSynced = false; // Track if RTC has been synced with NTP + +// Scrolling state +int scrollOffset = 0; +int messageLength = 0; + +// Frame buffer - 8 rows x 12 columns +uint8_t frameBuffer[MATRIX_ROWS][MATRIX_COLS] = {0}; + +int getBracketPeriod(long ageSec) { + if (ageSec < 15) return 150; + if (ageSec < 30) return 200; + if (ageSec < 45) return 250; + if (ageSec < 60) return 350; + if (ageSec < 75) return 500; + if (ageSec < 90) return 700; + if (ageSec < 105) return 1000; + if (ageSec < 120) return 1400; + if (ageSec < 135) return 1800; + if (ageSec < 150) return 2200; + if (ageSec < 165) return 2600; + return 3000; +} + +int getBracketDuty(long ageSec) { + if (ageSec < 15) return 55; + if (ageSec < 30) return 48; + if (ageSec < 45) return 40; + if (ageSec < 60) return 35; + if (ageSec < 75) return 30; + if (ageSec < 90) return 25; + if (ageSec < 105) return 20; + if (ageSec < 120) return 16; + if (ageSec < 135) return 12; + if (ageSec < 150) return 10; + if (ageSec < 165) return 8; + return 6; +} + +void gaugeAddMessage(long unixTime) { + if (gaugeCount < MAX_GAUGE_ENTRIES) { + gaugeTimestamps[gaugeCount++] = unixTime; + } else { + for (int i = 0; i < MAX_GAUGE_ENTRIES - 1; i++) { + gaugeTimestamps[i] = gaugeTimestamps[i + 1]; + } + gaugeTimestamps[MAX_GAUGE_ENTRIES - 1] = unixTime; + } +} + +void gaugePruneOld(long nowUnix) { + int w = 0; + for (int i = 0; i < gaugeCount; i++) { + if (nowUnix - gaugeTimestamps[i] <= GAUGE_MAX_AGE_MS / 1000) { + gaugeTimestamps[w++] = gaugeTimestamps[i]; + } + } + gaugeCount = w; +} + +void renderGauge(long nowUnix) { + unsigned long ms = millis(); + for (int col = 0; col < MATRIX_COLS; col++) { + int bucketMin = col * GAUGE_BUCKET_SEC; + int bucketMax = bucketMin + GAUGE_BUCKET_SEC; + + int messagesInBucket = 0; + long youngestAge = GAUGE_MAX_AGE_MS / 1000 + 1; + + for (int i = 0; i < gaugeCount; i++) { + long age = nowUnix - gaugeTimestamps[i]; + if (age >= bucketMin && age < bucketMax) { + messagesInBucket++; + if (age < youngestAge) youngestAge = age; + } + } + + if (messagesInBucket == 0) { + frameBuffer[MATRIX_ROWS - 1][col] = 0; + continue; + } + + int periodMs = getBracketPeriod(youngestAge); + int dutyPct = getBracketDuty(youngestAge); + + int phase = ms % periodMs; + int onThreshold = periodMs * dutyPct / 100; + frameBuffer[MATRIX_ROWS - 1][col] = (phase < onThreshold) ? 1 : 0; + } +} + +void setup() { + Serial.begin(115200); + delay(1000); + Serial.println("\n=== Klubhaus Doorbell - Uno R4 WiFi ==="); + Serial.print("Build token: "); + Serial.println(BUILD_TOKEN); + Serial.println("Starting up..."); + + // Initialize RTC + RTC.begin(); + Serial.println("RTC initialized"); + + bootTime = millis(); + bootDelayEndTime = bootTime + 10000; // Wait 10 seconds after boot before processing + Serial.print("Boot time: "); + Serial.print(bootTime); + Serial.print(", will start processing at: "); + Serial.println(bootDelayEndTime); + + Serial.println("Initializing LED matrix..."); + matrix.begin(); + delay(100); + matrix.clear(); + Serial.println("LED matrix initialized"); + + Serial.println("Displaying OK..."); + displayStaticText("OK"); + delay(500); // Display OK for 500ms + matrix.clear(); + + Serial.println("Starting WiFi connection..."); + connectToWiFi(); + + // Sync RTC with NTP after WiFi connection + syncRTCWithNTP(); +} + +void loop() { + unsigned long now = millis(); + + // Heartbeat LED + static unsigned long lastHeartbeat = 0; + static const unsigned long FADE_PERIOD_MS = 10000; + static const int MIN_BRIGHTNESS = 51; + static const int MAX_BRIGHTNESS = 204; + if (now - lastHeartbeat >= 50) { + float phase = (float)(now % FADE_PERIOD_MS) / (float)FADE_PERIOD_MS * 2.0 * PI; + analogWrite(LED_BUILTIN, MIN_BRIGHTNESS + (int)((MAX_BRIGHTNESS - MIN_BRIGHTNESS) / 2.0 * (1.0 + sin(phase)))); + lastHeartbeat = now; + } + + // One RTC read per loop + RTCTime ct; + RTC.getTime(ct); + long nowUnix = ct.getUnixTime(); + + // Non-blocking poll — one step per loop + if (rtcSynced && now >= bootDelayEndTime) { + pollNtfy(nowUnix); + } + + // Clear frame, then always render gauge on row 7 + memset(frameBuffer, 0, sizeof(frameBuffer)); + gaugePruneOld(nowUnix); + renderGauge(nowUnix); + + switch (currentState) { + case STATE_IDLE: + if (currentMessage.length() > 0) { + Serial.print("[DISPLAY] "); + Serial.println(currentMessage); + currentState = STATE_DISPLAYING_MESSAGE; + messageStartTime = now; + lastScrollTime = now; + scrollOffset = -MATRIX_COLS; + messageLength = currentMessage.length(); + } + break; + + case STATE_DISPLAYING_MESSAGE: + // Always draw text at current scroll position (not just on tick) + drawScrollingText(); + + // Advance scroll on timer tick + if (now - lastScrollTime >= SCROLL_SPEED_MS) { + scrollOffset += 1; + if (scrollOffset > messageLength * 6 + MATRIX_COLS) { + scrollOffset = -MATRIX_COLS; + } + lastScrollTime = now; + } + + if (now - messageStartTime >= DISPLAY_DURATION_MS) { + lastDisplayedMessage = currentMessage; + lastDisplayedMessageTime = currentMessageTime; + currentState = STATE_IDLE; + currentMessage = ""; + scrollOffset = 0; + } + break; + } + + // Non-blocking poll — start cycle on timer, advance one step per loop + static unsigned long lastPollStart = 0; + if (pollState == POLL_IDLE && now - lastPollStart >= POLL_INTERVAL_MS) { + if (rtcSynced && now >= bootDelayEndTime && WiFi.status() == WL_CONNECTED) { + pollState = POLL_CONNECT; + lastPollStart = now; + } + } + if (pollState != POLL_IDLE) { + pollNtfy(nowUnix); + } + + matrix.renderBitmap(frameBuffer, MATRIX_ROWS, MATRIX_COLS); +} + +void connectToWiFi() { + Serial.print("Connecting to WiFi: "); + Serial.println(WIFI_SSID); + + if (WiFi.status() == WL_NO_MODULE) { + Serial.println("ERROR: Communication with WiFi module failed!"); + displayStaticText("ERR"); + while (true); + } + + String fv = WiFi.firmwareVersion(); + Serial.print("WiFi firmware version: "); + Serial.println(fv); + + Serial.print("Attempting WiFi connection..."); + WiFi.begin(WIFI_SSID, WIFI_PASS); + + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 20) { + Serial.print("."); + delay(1000); + attempts++; + } + + if (WiFi.status() != WL_CONNECTED) { + Serial.println("\nERROR: Failed to connect to WiFi"); + displayStaticText("WIFI ERR"); + while (true); + } + + Serial.println("\nWiFi connected!"); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); +} + +void syncRTCWithNTP() { + Serial.println("Syncing RTC with NTP..."); + + unsigned long epochTime = WiFi.getTime(); + + if (epochTime > 0) { + // Set timezone to UTC (no offset) + RTCTime timeToSet = RTCTime(epochTime); + RTC.setTime(timeToSet); + + // Get current time to verify + RTCTime currentTime; + RTC.getTime(currentTime); + Serial.print("RTC synced to: "); + Serial.println(String(currentTime)); + + rtcSynced = true; + + readyTime = epochTime; // Reject messages older than this boot + Serial.print("Ready time: "); + Serial.println((unsigned long)readyTime); + } else { + Serial.println("ERROR: Failed to get NTP time"); + Serial.println("Make sure WiFi firmware version is at least 0.5.0"); + } +} + +void pollNtfy(long nowUnix) { + unsigned long now = millis(); + + switch (pollState) { + case POLL_CONNECT: + if (client.connect("ntfy.sh", 80)) { + pollState = POLL_SEND; + } else { + pollState = POLL_IDLE; + } + break; + + case POLL_SEND: { + String url; + if (lastMessageId.length() > 0) { + url = "/" + String(NTFY_TOPIC) + "/json?poll=1&since=" + lastMessageId; + } else { + url = "/" + String(NTFY_TOPIC) + "/json?poll=1&since=latest"; + } + client.print(String("GET ") + url + " HTTP/1.1\r\n" + + "Host: ntfy.sh\r\n" + + "Connection: close\r\n\r\n"); + pollStepTime = now; + pollResponse = ""; + pollState = POLL_READ; + break; + } + + case POLL_READ: { + // Non-blocking read — only consume what's available, max 20ms per call + unsigned long t = millis(); + while (client.available() > 0 && (millis() - t) < 20) { + pollResponse += (char)client.read(); + } + // Done when disconnected and we have content, or timeout + if (!client.connected() || (pollResponse.length() > 0 && now - pollStepTime > 3000)) { + client.stop(); + pollState = POLL_PARSE; + } else if (now - pollStepTime > 8000) { + client.stop(); + pollState = POLL_IDLE; + } + break; + } + + case POLL_PARSE: { + int js = pollResponse.indexOf("{"); + if (js >= 0) { + StaticJsonDocument<1024> doc; + if (!deserializeJson(doc, pollResponse.substring(js))) { + processMessage(doc, nowUnix); + } + } + pollState = POLL_IDLE; + break; + } + + default: + break; + } +} + +void processMessage(JsonDocument& doc, long nowUnix) { + const char* message = doc["message"]; + const char* id = doc["id"]; + long msgTime = doc["time"]; + + if (!message || strlen(message) == 0 || msgTime <= 0) return; + + String msgStr = String(message); + + if (msgStr == lastDisplayedMessage) { + if (id) lastMessageId = String(id); + return; + } + if (lastDisplayedMessageTime > 0 && msgTime <= lastDisplayedMessageTime) { + if (id) lastMessageId = String(id); + return; + } + if (readyTime > 0 && msgTime < readyTime) { + if (id) lastMessageId = String(id); + return; + } + if (nowUnix - msgTime > 3600) { + if (id) lastMessageId = String(id); + return; + } + + Serial.print("[ACCEPTED] "); + Serial.println(msgStr); + + gaugePruneOld(nowUnix); + gaugeAddMessage(msgTime); + + if (currentState == STATE_IDLE) { + currentMessage = msgStr; + currentMessageTime = msgTime; + } + if (id) lastMessageId = String(id); +} + +void drawScrollingText() { + int charWidth = 5; + int charSpacing = 1; + + for (int i = 0; i < messageLength; i++) { + char c = currentMessage[i]; + int charX = i * (charWidth + charSpacing) - scrollOffset; + if (charX > -charWidth && charX < MATRIX_COLS) { + drawCharToFrame(c, charX); + } + } +} + +void drawCharToFrame(char c, int x) { + static const uint8_t font[][5] = { + // Uppercase A-Z (indices 0-25) + {0x7F, 0x41, 0x41, 0x41, 0x7F}, // 'A' + {0x7F, 0x49, 0x49, 0x49, 0x36}, // 'B' + {0x3E, 0x41, 0x41, 0x41, 0x22}, // 'C' + {0x7F, 0x41, 0x41, 0x22, 0x1C}, // 'D' + {0x7F, 0x49, 0x49, 0x49, 0x41}, // 'E' + {0x7F, 0x09, 0x09, 0x09, 0x01}, // 'F' + {0x3E, 0x41, 0x49, 0x49, 0x7A}, // 'G' + {0x7F, 0x08, 0x08, 0x08, 0x7F}, // 'H' + {0x00, 0x41, 0x7F, 0x41, 0x00}, // 'I' + {0x20, 0x40, 0x41, 0x3F, 0x00}, // 'J' + {0x7F, 0x08, 0x14, 0x22, 0x41}, // 'K' + {0x7F, 0x40, 0x40, 0x40, 0x40}, // 'L' + {0x7F, 0x02, 0x0C, 0x02, 0x7F}, // 'M' + {0x7F, 0x04, 0x08, 0x10, 0x7F}, // 'N' + {0x3E, 0x41, 0x41, 0x41, 0x3E}, // 'O' + {0x7F, 0x09, 0x09, 0x09, 0x06}, // 'P' + {0x3E, 0x41, 0x51, 0x21, 0x5E}, // 'Q' + {0x7F, 0x09, 0x19, 0x29, 0x46}, // 'R' + {0x46, 0x49, 0x49, 0x49, 0x31}, // 'S' + {0x01, 0x01, 0x7F, 0x01, 0x01}, // 'T' + {0x3F, 0x40, 0x40, 0x40, 0x3F}, // 'U' + {0x1F, 0x20, 0x40, 0x20, 0x1F}, // 'V' + {0x7F, 0x20, 0x18, 0x20, 0x7F}, // 'W' + {0x63, 0x14, 0x08, 0x14, 0x63}, // 'X' + {0x07, 0x08, 0x70, 0x08, 0x07}, // 'Y' + {0x61, 0x51, 0x49, 0x45, 0x43}, // 'Z' + // Lowercase a-z (indices 26-51) + {0x7F, 0x09, 0x09, 0x09, 0x7F}, // 'a' + {0x7F, 0x49, 0x49, 0x49, 0x36}, // 'b' + {0x3E, 0x41, 0x41, 0x41, 0x22}, // 'c' + {0x7F, 0x41, 0x41, 0x22, 0x1C}, // 'd' + {0x7F, 0x49, 0x49, 0x49, 0x41}, // 'e' + {0x7F, 0x09, 0x09, 0x09, 0x01}, // 'f' + {0x3E, 0x41, 0x49, 0x49, 0x7A}, // 'g' + {0x7F, 0x08, 0x08, 0x08, 0x7F}, // 'h' + {0x00, 0x41, 0x7F, 0x41, 0x00}, // 'i' + {0x20, 0x40, 0x41, 0x3F, 0x00}, // 'j' + {0x7F, 0x08, 0x14, 0x22, 0x41}, // 'k' + {0x7F, 0x40, 0x40, 0x40, 0x40}, // 'l' + {0x7F, 0x02, 0x0C, 0x02, 0x7F}, // 'm' + {0x7F, 0x04, 0x08, 0x10, 0x7F}, // 'n' + {0x3E, 0x41, 0x41, 0x41, 0x3E}, // 'o' + {0x7F, 0x09, 0x09, 0x09, 0x06}, // 'p' + {0x3E, 0x41, 0x51, 0x21, 0x5E}, // 'q' + {0x7F, 0x09, 0x19, 0x29, 0x46}, // 'r' + {0x46, 0x49, 0x49, 0x49, 0x31}, // 's' + {0x01, 0x01, 0x7F, 0x01, 0x01}, // 't' + {0x3F, 0x40, 0x40, 0x40, 0x3F}, // 'u' + {0x1F, 0x20, 0x40, 0x20, 0x1F}, // 'v' + {0x7F, 0x20, 0x18, 0x20, 0x7F}, // 'w' + {0x63, 0x14, 0x08, 0x14, 0x63}, // 'x' + {0x07, 0x08, 0x70, 0x08, 0x07}, // 'y' + {0x61, 0x51, 0x49, 0x45, 0x43}, // 'z' + // Numbers and space (indices 52-62) + {0x3E, 0x51, 0x49, 0x45, 0x3E}, // '0' + {0x00, 0x42, 0x7F, 0x40, 0x00}, // '1' + {0x42, 0x61, 0x51, 0x49, 0x46}, // '2' + {0x21, 0x41, 0x45, 0x4B, 0x31}, // '3' + {0x18, 0x14, 0x12, 0x7F, 0x10}, // '4' + {0x27, 0x45, 0x45, 0x45, 0x39}, // '5' + {0x3C, 0x4A, 0x49, 0x49, 0x30}, // '6' + {0x01, 0x71, 0x09, 0x05, 0x03}, // '7' + {0x36, 0x49, 0x49, 0x49, 0x36}, // '8' + {0x06, 0x49, 0x49, 0x29, 0x1E}, // '9' + {0x00, 0x00, 0x00, 0x00, 0x00}, // ' ' (space) + }; + + int fontIndex = -1; + + if (c >= 'A' && c <= 'Z') { + fontIndex = c - 'A'; + } + else if (c >= 'a' && c <= 'z') { + fontIndex = 26 + (c - 'a'); + } + else if (c >= '0' && c <= '9') { + fontIndex = 52 + (c - '0'); + } + else if (c == ' ') { + fontIndex = 62; + } + else { + return; + } + + if (fontIndex >= 0) { + for (int col = 0; col < 5; col++) { + uint8_t colData = font[fontIndex][col]; + int drawX = x + col; + + if (drawX >= 0 && drawX < MATRIX_COLS) { + for (int row = 0; row < MATRIX_ROWS; row++) { + if (colData & (1 << row)) { + frameBuffer[row][drawX] = 1; + } + } + } + } + } +} + +void displayStaticText(String text) { + memset(frameBuffer, 0, sizeof(frameBuffer)); + + int textWidth = text.length() * 6; + int startX = max(0, (MATRIX_COLS - textWidth) / 2); + + for (int i = 0; i < text.length(); i++) { + drawCharToFrame(text[i], startX + i * 6); + } + + matrix.renderBitmap(frameBuffer, MATRIX_ROWS, MATRIX_COLS); +}