|
// Clubhouse dB Meter — Indy Hall |
|
// M5Stack StickC S3 sound level monitor |
|
// |
|
// Features: |
|
// - Live dB metering with smoothing |
|
// - Segmented VU bar (50–100 dB), color-coded OK/LOUD/TOO LOUD |
|
// - Peak hold with white tick |
|
// - Discord webhook alerts when sustained loud (3+ samples at ≥92 dB) |
|
// - ThingSpeak logging every 5 minutes |
|
// - BtnA menu: click to open/cycle, hold to confirm |
|
// - Power Off menu item |
|
// - OTA firmware updates via ArduinoOTA |
|
|
|
#include <M5Unified.h> |
|
#include <WiFi.h> |
|
#include <HTTPClient.h> |
|
#include <ArduinoOTA.h> |
|
#include <cmath> |
|
|
|
const char* WIFI_SSID = "Your WiFi Name"; |
|
const char* WIFI_PASS = "Your WiFi Password"; |
|
const char* DISCORD_HOOK = "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"; |
|
const char* THINGSPEAK_KEY = "YOUR_THINGSPEAK_KEY"; |
|
|
|
#define THRESHOLD_WARN 85 |
|
#define THRESHOLD_ALERT 92 |
|
#define BAR_DB_MIN 50 |
|
#define SAMPLE_SIZE 512 |
|
#define LOG_INTERVAL_MS 300000 |
|
#define SAMPLE_INTERVAL_MS 500 |
|
#define PEAK_HOLD_MS 3000 |
|
#define ALERT_COOLDOWN_MS 60000 |
|
#define BRIGHTNESS_DIM 64 |
|
#define BRIGHTNESS_IDLE 20 |
|
#define BRIGHTNESS_FULL 255 |
|
#define IDLE_TIMEOUT_MS 30000 |
|
|
|
// Display (landscape): 240×135 |
|
#define LCD_W 240 |
|
#define LCD_H 135 |
|
#define BAR_X 4 |
|
#define BAR_Y 60 |
|
#define BAR_W (LCD_W - 8) // 232 |
|
#define BAR_H 40 |
|
#define N_SEGS 46 |
|
#define SEG_W 4 |
|
#define SEG_GAP 1 |
|
|
|
int16_t mic_buf[SAMPLE_SIZE]; |
|
unsigned long lastAlert = 0; |
|
unsigned long lastLog = 0; |
|
unsigned long lastSample = 0; |
|
unsigned long lastWifiCheck = 0; |
|
unsigned long lastChangeMs = 0; |
|
float smoothDb = 0; |
|
float lastDisplayDb = -1; |
|
float peakDb = 0; |
|
unsigned long peakTime = 0; |
|
int prevStatus = -1; // -1=unset, 0=OK, 1=WARN, 2=ALERT |
|
|
|
// Menu |
|
#define MENU_TIMEOUT_MS 5000 |
|
const char* MENU_LABELS[] = {"Power Off"}; |
|
const int MENU_COUNT = 1; |
|
bool menuActive = false; |
|
int menuItem = 0; |
|
unsigned long menuOpenedAt = 0; |
|
|
|
void drawVuBar(float db, uint16_t fg, uint16_t bg) { |
|
uint16_t darkGray = M5.Lcd.color565(40, 40, 40); |
|
uint16_t matGreen = M5.Lcd.color565(76, 175, 80); |
|
|
|
float range = 100.0f - BAR_DB_MIN; |
|
int greenSegs = (int)((THRESHOLD_WARN - BAR_DB_MIN) / range * N_SEGS); |
|
int orangeSegs = (int)((THRESHOLD_ALERT - BAR_DB_MIN) / range * N_SEGS); |
|
int fillSegs = constrain((int)((db - BAR_DB_MIN) / range * N_SEGS), 0, N_SEGS); |
|
int peakSeg = (peakDb > db && peakDb > BAR_DB_MIN) |
|
? constrain((int)((peakDb - BAR_DB_MIN) / range * N_SEGS), 0, N_SEGS - 1) |
|
: -1; |
|
|
|
M5.Lcd.drawRect(BAR_X - 1, BAR_Y - 1, BAR_W + 2, BAR_H + 2, fg); |
|
|
|
// Paint every segment filled or dark — no full-region clear |
|
for (int i = 0; i < N_SEGS; i++) { |
|
int sx = BAR_X + 2 + i * (SEG_W + SEG_GAP); |
|
uint16_t c; |
|
if (i == peakSeg) c = TFT_WHITE; |
|
else if (i < fillSegs) c = (i < greenSegs) ? matGreen : (i < orangeSegs) ? TFT_ORANGE : TFT_RED; |
|
else c = darkGray; |
|
M5.Lcd.fillRect(sx, BAR_Y + 2, SEG_W, BAR_H - 4, c); |
|
} |
|
|
|
// Threshold markers below bar |
|
int twarnX = BAR_X + 2 + (int)((THRESHOLD_WARN - BAR_DB_MIN) / range * N_SEGS) * (SEG_W + SEG_GAP) + SEG_W / 2; |
|
int talertX = BAR_X + 2 + (int)((THRESHOLD_ALERT - BAR_DB_MIN) / range * N_SEGS) * (SEG_W + SEG_GAP) + SEG_W / 2; |
|
int tickY = BAR_Y + BAR_H + 2; |
|
int lblY = tickY + 4; |
|
|
|
M5.Lcd.drawFastVLine(twarnX, tickY, 3, fg); |
|
M5.Lcd.drawFastVLine(talertX, tickY, 3, fg); |
|
|
|
M5.Lcd.setTextSize(1); |
|
M5.Lcd.setTextColor(fg, bg); |
|
M5.Lcd.setCursor(twarnX - 6, lblY); |
|
M5.Lcd.print(THRESHOLD_WARN); |
|
M5.Lcd.setCursor(talertX - 6, lblY); |
|
M5.Lcd.print(THRESHOLD_ALERT); |
|
} |
|
|
|
void updateDisplay(float db) { |
|
uint16_t bg; |
|
uint16_t fg = TFT_WHITE; |
|
const char* label; |
|
int status; |
|
|
|
if (db >= THRESHOLD_ALERT) { |
|
status = 2; bg = TFT_RED; label = "TOO LOUD"; |
|
M5.Lcd.setBrightness(BRIGHTNESS_FULL); |
|
} else if (db >= THRESHOLD_WARN) { |
|
status = 1; bg = TFT_ORANGE; fg = TFT_BLACK; label = "LOUD"; |
|
M5.Lcd.setBrightness(BRIGHTNESS_FULL); |
|
} else { |
|
status = 0; bg = M5.Lcd.color565(76, 175, 80); label = "OK"; |
|
unsigned long idle = millis() - lastChangeMs; |
|
M5.Lcd.setBrightness(idle > IDLE_TIMEOUT_MS ? BRIGHTNESS_IDLE : BRIGHTNESS_DIM); |
|
} |
|
|
|
bool statusChanged = (status != prevStatus); |
|
|
|
if (status == 2 && prevStatus != 2) { |
|
M5.Lcd.fillScreen(TFT_WHITE); delay(100); |
|
M5.Lcd.fillScreen(TFT_BLACK); delay(100); |
|
} |
|
prevStatus = status; |
|
|
|
if (statusChanged) { |
|
M5.Lcd.fillScreen(bg); |
|
} else { |
|
M5.Lcd.fillRect(0, 0, LCD_W, 57, bg); |
|
M5.Lcd.fillRect(0, 115, LCD_W, LCD_H - 115, bg); |
|
} |
|
|
|
M5.Lcd.setTextColor(fg, bg); |
|
|
|
// Number (textSize 6: 36px/char wide, 48px tall) + label (textSize 2: 12px/char wide, 16px tall) |
|
// drawn together, horizontally centered as a unit |
|
char dbStr[8]; |
|
sprintf(dbStr, "%.0f", db); |
|
int numW = strlen(dbStr) * 36; |
|
int lblW = strlen(label) * 12; |
|
int unitW = numW + 10 + lblW; |
|
int startX = (LCD_W - unitW) / 2; |
|
|
|
M5.Lcd.setTextSize(6); |
|
M5.Lcd.setCursor(startX, 5); |
|
M5.Lcd.print(dbStr); |
|
|
|
// Label vertically centered within the number's 48px height |
|
M5.Lcd.setTextSize(2); |
|
M5.Lcd.setCursor(startX + numW + 10, 5 + 16); |
|
M5.Lcd.print(label); |
|
|
|
// VU meter bar |
|
drawVuBar(db, fg, bg); |
|
|
|
// Bottom info row (textSize 1: 6px/char wide, 8px tall) |
|
M5.Lcd.setTextSize(1); |
|
M5.Lcd.setTextColor(fg, bg); |
|
|
|
M5.Lcd.setCursor(BAR_X, 122); |
|
M5.Lcd.print(WiFi.localIP().toString()); |
|
|
|
// Cooldown countdown centered |
|
unsigned long alertAge = millis() - lastAlert; |
|
if (lastAlert > 0 && alertAge < ALERT_COOLDOWN_MS) { |
|
char cdStr[12]; |
|
sprintf(cdStr, "cd:%ds", (int)((ALERT_COOLDOWN_MS - alertAge) / 1000 + 1)); |
|
int cdW = strlen(cdStr) * 6; |
|
M5.Lcd.setCursor((LCD_W - cdW) / 2, 122); |
|
M5.Lcd.print(cdStr); |
|
} |
|
|
|
// Battery right-aligned |
|
int bat = M5.Power.getBatteryLevel(); |
|
String batStr = (M5.Power.isCharging() ? "+" : "") + String(bat) + "%"; |
|
M5.Lcd.setCursor(LCD_W - batStr.length() * 6 - BAR_X, 122); |
|
M5.Lcd.print(batStr); |
|
} |
|
|
|
void drawMenu() { |
|
int bx = 30, by = 30, bw = 180, bh = 75; |
|
M5.Lcd.fillRoundRect(bx, by, bw, bh, 6, TFT_BLACK); |
|
M5.Lcd.drawRoundRect(bx, by, bw, bh, 6, TFT_WHITE); |
|
|
|
M5.Lcd.setTextSize(1); |
|
M5.Lcd.setTextColor(TFT_YELLOW, TFT_BLACK); |
|
M5.Lcd.setCursor(bx + 6, by + 6); |
|
M5.Lcd.print("MENU"); |
|
|
|
for (int i = 0; i < MENU_COUNT; i++) { |
|
M5.Lcd.setTextColor(i == menuItem ? TFT_BLACK : TFT_WHITE, |
|
i == menuItem ? TFT_WHITE : TFT_BLACK); |
|
M5.Lcd.setCursor(bx + 8, by + 22 + i * 14); |
|
M5.Lcd.print(i == menuItem ? "> " : " "); |
|
M5.Lcd.print(MENU_LABELS[i]); |
|
} |
|
|
|
M5.Lcd.setTextColor(M5.Lcd.color565(120, 120, 120), TFT_BLACK); |
|
M5.Lcd.setCursor(bx + 6, by + bh - 14); |
|
M5.Lcd.print("click:cycle hold:select"); |
|
} |
|
|
|
void executeMenuItem(int item) { |
|
if (item == 0) { |
|
M5.Lcd.fillScreen(TFT_BLACK); |
|
M5.Lcd.setTextColor(TFT_WHITE); |
|
M5.Lcd.setTextSize(2); |
|
M5.Lcd.setCursor(60, 55); |
|
M5.Lcd.print("Bye!"); |
|
delay(800); |
|
M5.Power.powerOff(); |
|
} |
|
} |
|
|
|
void sendDiscordAlert(float db) { |
|
if (millis() - lastAlert < ALERT_COOLDOWN_MS) return; |
|
HTTPClient http; |
|
http.begin(DISCORD_HOOK); |
|
http.addHeader("Content-Type", "application/json"); |
|
String body = "{\"content\":\"Clubhouse sound alert: " + String((int)db) + " dB — too loud!\"}"; |
|
http.POST(body); |
|
http.end(); |
|
lastAlert = millis(); |
|
} |
|
|
|
void logToThingSpeak(float db) { |
|
unsigned long now = millis(); |
|
if (now - lastLog < LOG_INTERVAL_MS) return; |
|
lastLog = now; |
|
HTTPClient http; |
|
String url = String("https://api.thingspeak.com/update?api_key=") + THINGSPEAK_KEY + "&field1=" + String(db, 1); |
|
http.begin(url); |
|
http.GET(); |
|
http.end(); |
|
} |
|
|
|
void setup() { |
|
auto cfg = M5.config(); |
|
cfg.internal_mic = true; |
|
M5.begin(cfg); |
|
M5.Lcd.setRotation(1); |
|
M5.Lcd.fillScreen(TFT_BLACK); |
|
M5.Lcd.setTextColor(TFT_WHITE); |
|
M5.Lcd.setTextSize(2); |
|
M5.Lcd.setCursor(8, 50); |
|
M5.Lcd.print("Connecting..."); |
|
|
|
WiFi.begin(WIFI_SSID, WIFI_PASS); |
|
int tries = 0; |
|
while (WiFi.status() != WL_CONNECTED && tries++ < 20) delay(500); |
|
|
|
ArduinoOTA.setHostname("clubhouse-db-meter"); |
|
ArduinoOTA.begin(); |
|
|
|
M5.Mic.begin(); |
|
lastLog = millis(); |
|
lastChangeMs = millis(); |
|
} |
|
|
|
void loop() { |
|
ArduinoOTA.handle(); |
|
M5.update(); |
|
|
|
// Button: click opens/cycles menu, hold confirms selection |
|
if (M5.BtnA.wasClicked()) { |
|
if (!menuActive) { |
|
menuActive = true; |
|
menuItem = 0; |
|
menuOpenedAt = millis(); |
|
prevStatus = -1; // force full redraw when menu closes |
|
drawMenu(); |
|
} else { |
|
menuItem = (menuItem + 1) % MENU_COUNT; |
|
menuOpenedAt = millis(); |
|
drawMenu(); |
|
} |
|
} |
|
if (M5.BtnA.wasHold() && menuActive) { |
|
menuActive = false; |
|
executeMenuItem(menuItem); |
|
} |
|
// Auto-dismiss after timeout |
|
if (menuActive && millis() - menuOpenedAt > MENU_TIMEOUT_MS) { |
|
menuActive = false; |
|
prevStatus = -1; |
|
} |
|
|
|
// WiFi self-heal |
|
if (millis() - lastWifiCheck > 30000) { |
|
lastWifiCheck = millis(); |
|
if (WiFi.status() != WL_CONNECTED) WiFi.reconnect(); |
|
} |
|
|
|
if (!menuActive && millis() - lastSample >= SAMPLE_INTERVAL_MS && M5.Mic.record(mic_buf, SAMPLE_SIZE, 16000)) { |
|
lastSample = millis(); |
|
float sum = 0; |
|
for (int i = 0; i < SAMPLE_SIZE; i++) sum += (float)mic_buf[i] * mic_buf[i]; |
|
float raw = 20.0f * log10f(sqrtf(sum / SAMPLE_SIZE) + 1.0f); |
|
|
|
smoothDb = (smoothDb == 0) ? raw : (0.7f * smoothDb + 0.3f * raw); |
|
|
|
// Peak hold |
|
if (smoothDb >= peakDb) { |
|
peakDb = smoothDb; |
|
peakTime = millis(); |
|
} else if (millis() - peakTime > PEAK_HOLD_MS) { |
|
peakDb = smoothDb; |
|
} |
|
|
|
if (fabsf(smoothDb - lastDisplayDb) >= 1.0f) { |
|
lastChangeMs = millis(); |
|
updateDisplay(smoothDb); |
|
lastDisplayDb = smoothDb; |
|
} |
|
|
|
if (smoothDb >= THRESHOLD_ALERT) sendDiscordAlert(smoothDb); |
|
logToThingSpeak(smoothDb); |
|
} |
|
} |