Skip to content

Instantly share code, notes, and snippets.

@alexknowshtml
Last active May 11, 2026 17:47
Show Gist options
  • Select an option

  • Save alexknowshtml/29680bebf7d98807037ad7572cd7d59f to your computer and use it in GitHub Desktop.

Select an option

Save alexknowshtml/29680bebf7d98807037ad7572cd7d59f to your computer and use it in GitHub Desktop.
Getting started with M5Stack StickC S3: WiFi, OTA firmware updates, and a real-world sound level meter with Discord alerts. Progressive examples from hello world to production.

M5StickC S3: Getting Started

Progressive guide from hello world to over-the-air updates to a real sound meter app.

Hardware

M5Stack StickC S3 (not the original StickC — different chip, display, and pinout)

  • ESP32-S3
  • 135×240 TFT LCD
  • Built-in PDM microphone
  • Built-in battery

Software Setup (Mac)

Install arduino-cli via Homebrew:

brew install arduino-cli

Add M5Stack board support:

arduino-cli config init
arduino-cli config add board_manager.additional_urls https://m5stack.oss-cn-shenzhen.aliyuncs.com/resource/arduino/package_m5stack_index.json
arduino-cli core update-index
arduino-cli core install m5stack:esp32

Install libraries:

arduino-cli lib install "M5Unified"

FQBN: m5stack:esp32:m5stack_sticks3

First Flash (USB — required once)

The device needs one USB flash to get ArduinoOTA onto it. After that, all updates are wireless.

  1. Put device in bootloader mode: hold the side button + tap the reset button until the screen goes black
  2. Connect USB-C
  3. Find the port: arduino-cli board list
  4. Flash:
arduino-cli compile --fqbn m5stack:esp32:m5stack_sticks3 --build-path /tmp/m5-build ./hello-world-ota/
arduino-cli upload -p /dev/cu.usbmodem101 --fqbn m5stack:esp32:m5stack_sticks3 /tmp/m5-build/

OTA Updates (all subsequent flashes)

Once the hello world sketch is running, get the IP from the screen, then:

arduino-cli compile --fqbn m5stack:esp32:m5stack_sticks3 --build-path /tmp/m5-build ./your-sketch/

python3 ~/Library/Arduino15/packages/m5stack/hardware/esp32/3.3.7/tools/espota.py \
  -i YOUR_DEVICE_IP -p 3232 \
  -f /tmp/m5-build/your-sketch.ino.bin

The IP shows at the bottom of the display after WiFi connects. You can also ping your-hostname.local from a Mac to find it.

Notes

  • The .ino filename must match the folder name (Arduino requirement)
  • Always use uint16_t for display colors — uint32_t uses the wrong color path
  • espota.py path varies by ESP32 core version — check ~/Library/Arduino15/packages/m5stack/hardware/esp32/
#include <M5Unified.h>
#include <WiFi.h>
#include <ArduinoOTA.h>
const char* WIFI_SSID = "Your WiFi Name";
const char* WIFI_PASS = "Your WiFi Password";
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
M5.Lcd.setRotation(1); // landscape
M5.Lcd.fillScreen(TFT_BLACK);
M5.Lcd.setTextColor(TFT_WHITE);
M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(10, 50);
M5.Lcd.print("Connecting...");
WiFi.begin(WIFI_SSID, WIFI_PASS);
int tries = 0;
while (WiFi.status() != WL_CONNECTED && tries++ < 20) delay(500);
M5.Lcd.fillScreen(TFT_BLACK);
if (WiFi.status() == WL_CONNECTED) {
ArduinoOTA.setHostname("m5sticks3");
ArduinoOTA.begin();
M5.Lcd.setTextSize(3);
M5.Lcd.setTextColor(TFT_GREEN);
M5.Lcd.setCursor(20, 20);
M5.Lcd.print("Hello!");
M5.Lcd.setTextSize(1);
M5.Lcd.setTextColor(TFT_WHITE);
M5.Lcd.setCursor(4, 122);
M5.Lcd.print(WiFi.localIP().toString());
} else {
M5.Lcd.setTextColor(TFT_RED);
M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(10, 50);
M5.Lcd.print("WiFi failed");
}
}
void loop() {
ArduinoOTA.handle();
M5.update();
}
// 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);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment