Files
klubhaus-doorbell/boards/uno-r4-wifi/uno-r4-wifi.ino
T
david 3af958b69f 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
2026-05-29 20:17:47 -07:00

564 lines
16 KiB
Arduino

//
// 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);
}