Community project

M5Cardputer XL: A Modified Cardputer ADV With an External Display

Jan Sz.

Published August 5, 2026 · Updated August 11, 2026

ESP320 components5 assembly steps
Remix this project
Photo of M5Cardputer XL: A Modified Cardputer ADV With an External Display

The M5Cardputer XL extends the M5Cardputer ADV with an external ILI9341 display, creating a portable computing device with a larger screen for better visibility. This project combines the compact keyboard and processing power of the Cardputer with a 3.2-inch TFT display, enabling applications like a launcher, WiFi scanner, notes app, calculator, clock, and QR code generator.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for connecting the external display via SPI. The included firmware handles display initialization, theme management, audio feedback, and a multi-page interface with persistent state storage. Builders will learn how to integrate external displays with ESP32-based devices and customize the application menu for their own projects.

Wiring diagram

Interactive · read-only

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Assembly

5 steps
  1. Odłącz zasilanie i sprawdź złącza

    Wyłącz Cardputer ADV i odłącz przewód USB. Zidentyfikuj na module ILI9341 piny VCC, GND, SCK/CLK, MOSI/DIN, MISO (jeśli występuje), CS, DC/RS, RST oraz BL/LED. Nie podłączaj ekranu, gdy Cardputer jest zasilany.

    • Tip: Nazwy SCK i CLK oznaczają tę samą linię; tak samo MOSI i DIN oraz DC i RS.
    • Tip: Użyj krótkich przewodów połączeniowych, najlepiej poniżej około 15 cm, aby ograniczyć zakłócenia SPI.
    • Nie zamieniaj VCC z GND — może to trwale uszkodzić ekran albo Cardputera.
    • GPIO Cardputera pracują z logiką 3,3 V. Nie podawaj 5 V na żaden pin sygnałowy GPIO.
  2. Połącz zasilanie i masę

    Połącz VCC modułu ili9341_1 z wyprowadzeniem 5V OUT Cardputera ADV. Połącz GND modułu ili9341_1 z GND Cardputera. To wspólna masa dla zasilania i wszystkich sygnałów.

    • Tip: W tabeli dostarczonej przez Ciebie VCC odpowiada 5V OUT Cardputera.
    • Tip: Jeżeli Twój konkretny moduł ma wyraźny opis „3.3V only”, zamiast 5V OUT podłącz jego VCC do szyny 3,3 V — nie stosuj jednocześnie obu napięć.
    • Najpierw wykonaj GND, dopiero potem przewód VCC.
    • Nie zasilaj modułu równocześnie z Cardputera i zewnętrznego zasilacza.
  3. Podłącz magistralę SPI ekranu

    Podłącz SCK/CLK modułu ili9341_1 do GPIO6 Cardputera, MOSI/DIN do GPIO3, a MISO do GPIO4. MISO jest przewodem powrotnym i firmware go nie wykorzystuje do rysowania, ale jest zgodny z podanym przez Ciebie pinoutem modułu.

    • Tip: Prowadź trzy przewody SPI obok siebie i trzymaj je możliwie krótkie.
    • Tip: Jeżeli używasz modułu bez pinu MISO, zostaw GPIO4 niepodłączone — ekran nadal będzie działał.
    • Nie podłączaj SCK ani MOSI do 5 V.
    • GPIO6, GPIO3 i GPIO4 są używane jako programowe SPI zgodnie z dostarczoną tabelą; nie przekładaj ich bez równoczesnej zmiany firmware.
  4. Podłącz sterowanie ILI9341

    Podłącz CS modułu ili9341_1 do GPIO5 Cardputera, DC/RS do GPIO13, RST do GPIO15, a BL/LED do GPIO39. W przypadku panelu z kontrolerem dotyku XPT2046 pin TOUCH_CS pozostaw podłączony do 3,3 V, aby kontroler dotyku był stale niewybrany i nie zakłócał magistrali.

    • Tip: DC/RS nie jest masą — to cyfrowa linia wyboru danych i poleceń.
    • Tip: Jeśli na module pin BL/LED jest opisany jako LED, podłącz go dokładnie tak samo do GPIO39.
    • Tip: Jeżeli masz moduł bez dotyku, krok z TOUCH_CS pomiń.
    • Nie łącz TOUCH_CS z GPIO4: GPIO4 jest MISO i nie może być równocześnie sygnałem wyboru układu.
    • GPIO39 jest równocześnie linią MISO wbudowanego microSD Cardputera ADV. W tym układzie nie używaj równocześnie microSD, bo podświetlenie zewnętrznego ekranu może powodować konflikt.
  5. Kontrola przed włączeniem

    Sprawdź po kolei wszystkie osiem połączeń: 5V OUT→VCC, GND→GND, GPIO6→SCK, GPIO3→MOSI, GPIO4→MISO, GPIO5→CS, GPIO13→DC, GPIO15→RST oraz GPIO39→BL. Dopiero po kontroli podłącz USB do Cardputera.

    • Tip: Po włączeniu podświetlenie zewnętrznego ekranu powinno się uaktywnić, a terminal cyberdecka pojawi się na ekranie ILI9341.
    • Tip: W terminalu można wpisać `help`, `about`, `clear` lub `echo tekst`, a następnie nacisnąć Enter.
    • Jeśli ekran nie działa, odłącz USB przed korektą przewodów.
    • Nie wyciągaj ani nie wkładaj przewodów zasilania przy działającym urządzeniu.

Firmware

ESP32
main.cppDeploy to device
#include <Arduino.h>
#include <M5Cardputer.h>
#include <SPI.h>
#include <WiFi.h>
#include <Preferences.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <qrcode.h>

#define TFT_CS 5
#define TFT_RST 15
#define TFT_DC 13
#define TFT_MOSI 3
#define TFT_MISO 4
#define TFT_SCK 6
#define TFT_BL 39

struct Theme { uint16_t bg, panel, accent, text, dim, selected; };
enum Page { LAUNCHER, SYSTEM, WIFI, NOTES, CLOCK, CALC, CLAB, QRTEXT, SETTINGS };
struct CVar { String name; long value; };
struct CStringVar { String name; String value; };
struct CFunction { String name; String body; };


// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations
void loadCLabDemo();

bool cBuiltinNumber(const String& name, long& value);
bool cBuiltinText(const String& name, String& value);
bool isCBuiltinName(const String& name);
void cWifiScan();

int findCStringVar(const String& name);

void applyVolume();

void markStateDirty();
void loadPersistentState();
void savePersistentState();
void applyTheme();
void playMenuSound();
void playTypingSound();
void playBackspaceSound();
void playExitSound();
void playCursorSound();
void playFunctionSound();
void playEnterSound();
void playTabSound();
void setBacklight(bool on);
void applyBacklight();
void drawBootScreen();
void header(const char* title);
void footer(const char* text);
void updateLauncherIndicators();
void updateLauncherSelection(int oldSelected, int oldScroll);
void updateSystemValues();
void drawSystem();
const char* enc(wifi_auth_mode_t e);
void drawWifi();
void drawNotes();
void updateNotesLine();
void updateClockValue();
void drawClock();
void updateCalcPanel();
void drawQRTextField();
void drawQRModules();
void drawQRText();
long cAtom(String token, bool& ok);
long cExpr(String expression, bool& ok);
String cTextExpr(String expression, bool& ok);
void cLog(const String& line);
void updateBuiltinDisplay(bool force = false);
int noteCharacterCount();

void drawLauncherRow(int row); void updateLauncherIndicators(); void updateLauncherSelection(int oldSelected, int oldScroll);
void drawSystemValue(int rowIndex, const String& value, uint16_t color); void updateSystemValues();
void handleCLabCursorKeys(); void loadPersistentState(); void savePersistentState(); void markStateDirty();
void syncLauncherScroll(); void moveCLabCursor(int direction);
int findCVar(const String& name); long cAtom(String token, bool& ok); long cExpr(String expression, bool& ok); void cLog(const String& line);
String uptime(); void applyTheme(); void setBacklight(bool on); void header(const char* title); void footer(const char* text);
void drawLauncher(); void drawSystem(); const char* enc(wifi_auth_mode_t e); void drawWifi(); void drawNotes(); void drawClock();
void drawCalc(); void updateClockValue(); void updateCalcPanel(); void updateNotesLine(); void updateSettingsRow(int index);
void drawCLab(); void drawCLabQR(); void drawQRText(); void drawQRTextField(); void drawQRModules(); void drawCLabEditor(); void drawCLabCodeLine(int lineIndex);
void runCLab(); void loadCLabDemo(); String settingValue(int i); void drawSettings(); void draw(); void startScan(); void checkScan(); void calcResult();
void changeSetting(int d); void keyboard(); void clabBackspace(); void playMenuSound(); void playTypingSound(); void playBackspaceSound();

Adafruit_ILI9341 tft(&SPI, TFT_DC, TFT_CS, TFT_RST);
Preferences preferences;
bool stateDirty = false;
unsigned long stateChangedAt = 0;
constexpr int W = 320, H = 240, HEADER_H = 22, FOOTER_H = 16, CONTENT_Y = 27;

constexpr int THEME_COUNT = 6;
const Theme themes[THEME_COUNT] = {
  // background, panel, accent, text, dim, selected
  {ILI9341_BLACK, ILI9341_DARKCYAN, ILI9341_CYAN, ILI9341_WHITE, ILI9341_LIGHTGREY, ILI9341_BLUE},
  {ILI9341_BLACK, 0x4200, ILI9341_YELLOW, 0xFFE0, 0xBDF7, 0x7BE0},
  {0x0010, 0x001F, 0x07FF, ILI9341_WHITE, 0xBDF7, 0x401F},
  {0x1008, 0x400F, 0xF81F, 0xFFE0, 0xC618, 0x801F},
  {0x0200, 0x03E0, 0x07E0, ILI9341_WHITE, 0xBDF7, 0x05A0},
  {0x2104, 0x4208, 0xFD20, 0xFFFF, 0xC618, 0xA145}
};
const char* themeNames[THEME_COUNT] = {"CYAN", "AMBER", "BLUE", "MAGENTA", "MATRIX", "SUNSET"};
int themeIndex = 0; Theme ui = themes[0];
uint8_t displayRotation = 3;
bool backlightOn = true, sleeping = false, redrawNeeded = true, fnLast = false;
uint8_t volumeLevel = 10;  // 0..10, persisted; 0 is silent.
uint8_t brightnessLevel = 10; // 1..10, persisted; PWM duty for the ILI backlight.
constexpr uint8_t BACKLIGHT_PWM_CHANNEL = 7;
constexpr uint16_t BACKLIGHT_PWM_HZ = 5000;
constexpr uint8_t BACKLIGHT_PWM_BITS = 8;
bool clabDeleteHeld = false;
unsigned long clabDeleteRepeatAt = 0;
const uint16_t sleepValues[] = {0, 15, 30, 60, 120};
int sleepIndex = 0; unsigned long lastActivity = 0;

Page page = LAUNCHER;
const char* appNames[] = {"SYSTEM", "WI-FI SCAN", "NOTES", "CLOCK", "CALCULATOR", "C LAB", "QR TEXT", "SETTINGS"};
const char* appInfo[] = {"battery, memory, uptime", "nearby networks", "quick text scratchpad", "local uptime clock", "basic arithmetic", "tiny C-style interpreter", "encode text as a QR", "theme and display options"};
constexpr int APP_COUNT = 8;
constexpr int APP_VISIBLE = 5;
int appSelected = 0, appScroll = 0;

String notes[15]; int noteLine = 0, noteCount = 1;
bool scanRunning = false, scanDone = false; int networkCount = 0; unsigned long scanStarted = 0;
String calcInput = "0", calcStatus = "Enter numbers, then + - * /"; float calcTotal = 0; char calcOp = 0; bool calcNew = true;
constexpr int QR_MAX_TEXT = 60;
String qrText = "https://m5stack.com";

constexpr int C_MAX_LINES = 32, C_MAX_LINE_CHARS = 48, C_CODE_VISIBLE_LINES = 8;
String cLines[C_MAX_LINES] = {
  "void setup() {", "  // put your setup code here, to run once:", "", "}", "",
  "void loop() {", "  // put your main code here, to run repeatedly:", "", "}"
};
int cLineCount = 9, cCursorLine = 0, cCursorColumn = 0, cScrollLine = 0;
constexpr int C_CODE_COLUMNS_VISIBLE = 40;
int cHorizontalScroll = 0;
bool cNavigationMode = false;
bool cLabGuideVisible = false;
String cOutput[5]; int cOutputCount = 0;
CVar cVars[12]; int cVarCount = 0;
CStringVar cStringVars[8]; int cStringVarCount = 0;
String cInputValues[4]; int cInputValueCount = 0, cInputReadIndex = 0;
bool cInputActive = false;
String cInputPrompt = "", cInputBuffer = "";
bool cLabQrActive = false;
String cLabQrPayload = "";
CFunction cFunctions[8]; int cFunctionCount = 0;

int settingSelected = 0;
const char* settingNames[] = {"Theme", "Rotation", "Backlight", "Brightness", "Sleep timeout", "Volume"};

String uptime() { unsigned long s = millis() / 1000UL; char x[18]; snprintf(x, sizeof(x), "%02lu:%02lu:%02lu", s / 3600UL, (s % 3600UL) / 60UL, s % 60UL); return String(x); }
int noteCharacterCount() { int total = 0; for (int i = 0; i < noteCount; ++i) total += notes[i].length(); return total; }

// Built-in Cardputer display: a small, independent companion panel.
// It is redrawn only when its displayed state changes.
void updateBuiltinDisplay(bool force) {
  static String previous = "";
  String title, value, detail;
  bool show = true;
  if (page == LAUNCHER) {
    title = "BATTERY";
    int battery = M5Cardputer.Power.getBatteryLevel();
    value = battery < 0 ? "--" : String(battery) + "%";
    detail = appNames[appSelected];
  } else if (page == WIFI) {
    title = "WI-FI";
    value = scanRunning ? "..." : String(networkCount);
    detail = scanRunning ? "SCANNING" : (scanDone ? "NETWORKS FOUND" : "PRESS ENTER TO SCAN");
  } else if (page == NOTES) {
    title = "NOTES";
    value = String(noteCharacterCount());
    detail = "CHARACTERS";
  } else if (page == CALC) {
    title = "CALCULATOR";
    value = calcInput;
    detail = calcStatus;
  } else if (page == CLAB) {
    title = "C LAB OUTPUT";
    value = "";
    for (int i = 0; i < cOutputCount; ++i) { if (i) value += "\n"; value += cOutput[i]; }
    if (value.isEmpty()) value = "(no output)";
    detail = "";
  } else {
    // System, Clock, QR Text and Settings intentionally leave this screen blank.
    show = false;
  }
  String signature = String((int)page) + "|" + title + "|" + value + "|" + detail + "|" + (show ? "1" : "0");
  if (!force && signature == previous) return;
  previous = signature;
  auto& screen = M5Cardputer.Display;
  screen.fillScreen(ui.bg);
  if (!show) return;
  screen.setTextWrap(false);
  screen.setTextSize(1);
  screen.setTextColor(ui.accent, ui.bg);
  screen.setCursor(8, 7);
  screen.print(title);
  screen.drawFastHLine(8, 18, 224, ui.dim);
  if (page == CLAB) {
    screen.setTextColor(ui.accent, ui.bg);
    int y = 30, start = max(0, cOutputCount - 7);
    for (int i = start; i < cOutputCount; ++i) { screen.setCursor(8, y); screen.print(cOutput[i]); y += 14; }
    if (cOutputCount == 0) { screen.setTextColor(ui.dim, ui.bg); screen.setCursor(8, 32); screen.print("(no output)"); }
  } else {
    String shown = value;
    if (shown.length() > 10) shown = shown.substring(shown.length() - 10);
    screen.setTextSize(page == CALC ? 3 : 5);
    screen.setTextColor(ui.text, ui.bg);
    screen.setCursor(8, 33);
    screen.print(shown);
    screen.setTextSize(1);
    screen.setTextColor(ui.dim, ui.bg);
    screen.setCursor(8, 108);
    screen.print(detail);
  }
}
void markStateDirty() { stateDirty = true; stateChangedAt = millis(); }

void loadPersistentState() {
  preferences.begin("cyberdeck", true);
  themeIndex = constrain(preferences.getInt("theme", themeIndex), 0, THEME_COUNT - 1);
  displayRotation = preferences.getUChar("rotation", displayRotation);
  if (displayRotation != 1 && displayRotation != 3) displayRotation = 3;
  backlightOn = preferences.getBool("backlight", backlightOn);
  brightnessLevel = constrain(preferences.getUChar("brightness", brightnessLevel), 1, 10);
  sleepIndex = constrain(preferences.getInt("sleep", sleepIndex), 0, 4);
  // Migrate the old Sound ON/OFF preference to a full-volume/default setting.
  if (preferences.isKey("volume")) volumeLevel = constrain(preferences.getUChar("volume", volumeLevel), 0, 10);
  else volumeLevel = preferences.getBool("sound", true) ? 10 : 0;
  String storedNotes = preferences.getString("notes", "");
  String storedCode = preferences.getString("code", "");
  qrText = preferences.getString("qrtext", qrText);
  preferences.end();
  if (!storedNotes.isEmpty()) {
    noteCount = 0; int start = 0;
    while (noteCount < 15) { int end = storedNotes.indexOf('\n', start); if (end < 0) { notes[noteCount++] = storedNotes.substring(start); break; } notes[noteCount++] = storedNotes.substring(start, end); start = end + 1; }
    if (noteCount == 0) noteCount = 1;
  }
  if (!storedCode.isEmpty()) {
    cLineCount = 0; int start = 0;
    while (cLineCount < C_MAX_LINES) { int end = storedCode.indexOf('\n', start); if (end < 0) { cLines[cLineCount++] = storedCode.substring(start); break; } cLines[cLineCount++] = storedCode.substring(start, end); start = end + 1; }
    if (cLineCount == 0) cLineCount = 1;
  }
}
void savePersistentState() {
  String storedNotes, storedCode;
  for (int i = 0; i < noteCount; ++i) { if (i) storedNotes += '\n'; storedNotes += notes[i]; }
  for (int i = 0; i < cLineCount; ++i) { if (i) storedCode += '\n'; storedCode += cLines[i]; }
  preferences.begin("cyberdeck", false);
  preferences.putInt("theme", themeIndex); preferences.putUChar("rotation", displayRotation); preferences.putBool("backlight", backlightOn); preferences.putUChar("brightness", brightnessLevel);
  preferences.putInt("sleep", sleepIndex); preferences.putUChar("volume", volumeLevel); preferences.putString("notes", storedNotes); preferences.putString("code", storedCode); preferences.putString("qrtext", qrText);
  preferences.end(); stateDirty = false;
}

void applyTheme() { ui = themes[themeIndex]; }
constexpr uint16_t MENU_TONE_HZ = 1050, TYPING_TONE_HZ = 1560, BACKSPACE_TONE_HZ = 1380;
constexpr uint16_t EXIT_TONE_HZ = 720, CURSOR_TONE_HZ = 1220, FUNCTION_TONE_HZ = 940, ENTER_TONE_HZ = 1740, TAB_TONE_HZ = 1480;
constexpr uint16_t MENU_TONE_MS = 24, TYPING_TONE_MS = 10, BACKSPACE_TONE_MS = 12;
constexpr uint16_t EXIT_TONE_MS = 32, CURSOR_TONE_MS = 9, FUNCTION_TONE_MS = 14, ENTER_TONE_MS = 20, TAB_TONE_MS = 16;
void applyVolume() { M5Cardputer.Speaker.setVolume((volumeLevel * 255U) / 10U); }
void playMenuSound() { if (volumeLevel) M5Cardputer.Speaker.tone(MENU_TONE_HZ, MENU_TONE_MS); }
void playTypingSound() { if (volumeLevel) M5Cardputer.Speaker.tone(TYPING_TONE_HZ, TYPING_TONE_MS); }
void playBackspaceSound() { if (volumeLevel) M5Cardputer.Speaker.tone(BACKSPACE_TONE_HZ, BACKSPACE_TONE_MS); }
void playExitSound() { if (volumeLevel) M5Cardputer.Speaker.tone(EXIT_TONE_HZ, EXIT_TONE_MS); }
void playCursorSound() { if (volumeLevel) M5Cardputer.Speaker.tone(CURSOR_TONE_HZ, CURSOR_TONE_MS); }
void playFunctionSound() { if (volumeLevel) M5Cardputer.Speaker.tone(FUNCTION_TONE_HZ, FUNCTION_TONE_MS); }
void playEnterSound() { if (volumeLevel) M5Cardputer.Speaker.tone(ENTER_TONE_HZ, ENTER_TONE_MS); }
void playTabSound() { if (volumeLevel) M5Cardputer.Speaker.tone(TAB_TONE_HZ, TAB_TONE_MS); }
void applyBacklight() {
  // GPIO 39 drives the module's BL control at 3.3 V through 8-bit PWM.
  uint8_t duty = backlightOn ? (uint16_t(brightnessLevel) * 255U) / 10U : 0;
  ledcWrite(BACKLIGHT_PWM_CHANNEL, duty);
}
void setBacklight(bool on) { backlightOn = on; applyBacklight(); }
void drawBootScreen() {
  // Full-screen splash: each text line has its own vertical area, so text cannot overlap.
  tft.fillScreen(ui.bg);
  tft.fillRect(0, 0, W, 5, ui.accent);
  tft.fillRect(0, H - 5, W, 5, ui.accent);
  tft.drawRoundRect(18, 28, 284, 184, 10, ui.panel);
  tft.drawRoundRect(23, 33, 274, 174, 8, ui.accent);

  tft.drawFastHLine(56, 68, 208, ui.dim);

  tft.setTextSize(3);
  tft.setTextColor(ui.accent, ui.bg);
  tft.setCursor(70, 82);
  tft.print("CARDPUTER");

  tft.setTextSize(5);
  tft.setTextColor(ui.text, ui.bg);
  tft.setCursor(128, 120);
  tft.print("XL");

  tft.drawFastHLine(56, 169, 208, ui.dim);
  tft.setTextSize(1);
  tft.setTextColor(ui.dim, ui.bg);
  tft.setCursor(94, 184);
  tft.print("CYBERDECK INTERFACE");
  delay(1200);
}
void header(const char* title) {
  tft.fillRect(0, 0, W, HEADER_H, ui.panel); tft.setTextSize(1); tft.setTextColor(ui.text, ui.panel); tft.setCursor(6, 7); tft.print("CARDPUTER XL");
  tft.setTextColor(ui.accent, ui.panel); tft.print(" / "); tft.print(title);
  int b = M5Cardputer.Power.getBatteryLevel(); if (b >= 0) { char v[8]; snprintf(v, sizeof(v), "%d%%", b); tft.setTextColor(b <= 20 ? ILI9341_RED : (b <= 50 ? ILI9341_YELLOW : ILI9341_GREEN), ui.panel); tft.setCursor(W - strlen(v) * 6 - 6, 7); tft.print(v); }
}
void footer(const char* text) { tft.fillRect(0, H - FOOTER_H, W, FOOTER_H, ILI9341_DARKGREY); tft.setTextSize(1); tft.setTextColor(ILI9341_WHITE, ILI9341_DARKGREY); tft.setCursor(5, H - 12); tft.print(text); }

void drawLauncherRow(int row) {
  int i = appScroll + row, y = CONTENT_Y + row * 33;
  tft.fillRect(8, y - 2, 284, 33, ui.bg); if (i >= APP_COUNT) return;
  bool sel = i == appSelected; uint16_t fill = sel ? ui.selected : ILI9341_DARKGREY;
  tft.fillRoundRect(12, y, 276, 29, 4, fill); tft.drawRoundRect(12, y, 276, 29, 4, sel ? ui.accent : ui.dim);
  tft.setTextSize(1); tft.setTextColor(ui.text, fill); tft.setCursor(20, y + 5); tft.print(sel ? "> " : "  "); tft.print(appNames[i]);
  tft.setTextColor(sel ? ui.text : ui.dim, fill); tft.setCursor(20, y + 16); tft.print(appInfo[i]);
}
void updateLauncherIndicators() {
  tft.fillRect(294, CONTENT_Y - 1, 22, APP_VISIBLE * 33, ui.bg);
  if (appScroll > 0) { tft.setTextColor(ui.accent, ui.bg); tft.setCursor(302, CONTENT_Y); tft.print("^"); }
  if (appScroll + APP_VISIBLE < APP_COUNT) { tft.setTextColor(ui.accent, ui.bg); tft.setCursor(302, CONTENT_Y + APP_VISIBLE * 33 - 10); tft.print("v"); }
}
void updateLauncherSelection(int oldSelected, int oldScroll) {
  if (oldScroll == appScroll) { drawLauncherRow(oldSelected - appScroll); drawLauncherRow(appSelected - appScroll); }
  else for (int row = 0; row < APP_VISIBLE; ++row) drawLauncherRow(row);
  updateLauncherIndicators();
}
void drawLauncher() { tft.fillScreen(ui.bg); header("LAUNCHER"); for (int row = 0; row < APP_VISIBLE; ++row) drawLauncherRow(row); updateLauncherIndicators(); footer(",/ PREV/NEXT     ENTER OPEN"); }

void drawSystemValue(int rowIndex, const String& value, uint16_t color) { int y = CONTENT_Y + 8 + rowIndex * 18; tft.fillRect(133, y - 1, 178, 10, ui.bg); tft.setTextSize(1); tft.setTextColor(color, ui.bg); tft.setCursor(133, y); tft.print(value); }
void updateSystemValues() { int b = M5Cardputer.Power.getBatteryLevel(); drawSystemValue(2, b < 0 ? "Unknown" : String(b) + "%", b <= 20 ? ILI9341_RED : ILI9341_GREEN); drawSystemValue(3, uptime(), ui.accent); drawSystemValue(4, String(ESP.getFreeHeap() / 1024) + " KB", ui.text); }
void drawSystem() {
  tft.fillScreen(ui.bg); header("SYSTEM"); int y = CONTENT_Y + 8, b = M5Cardputer.Power.getBatteryLevel();
  auto row = [&](const char* a, String v, uint16_t c) { tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(16, y); tft.print(a); tft.setTextColor(c, ui.bg); tft.setCursor(133, y); tft.print(v); y += 18; };
  row("DEVICE", "M5Stack Cardputer XL", ui.text); row("DISPLAY", "ILI9341 320x240", ILI9341_GREEN); row("BATTERY", b < 0 ? "Unknown" : String(b) + "%", b <= 20 ? ILI9341_RED : ILI9341_GREEN); row("UPTIME", uptime(), ui.accent); row("FREE HEAP", String(ESP.getFreeHeap() / 1024) + " KB", ui.text); row("HEAP TOTAL", String(ESP.getHeapSize() / 1024) + " KB", ui.text); row("FLASH", String(ESP.getFlashChipSize() / 1048576UL) + " MB", ui.text); row("THEME", themeNames[themeIndex], ui.accent); footer("FN BACK     ENTER REFRESH");
}
const char* enc(wifi_auth_mode_t e) { if (e == WIFI_AUTH_OPEN) return "OPEN"; if (e == WIFI_AUTH_WPA2_PSK || e == WIFI_AUTH_WPA_WPA2_PSK) return "WPA2"; if (e == WIFI_AUTH_WPA3_PSK) return "WPA3"; return "LOCK"; }
void drawWifi() {
  tft.fillScreen(ui.bg); header("WI-FI SCAN"); tft.setTextSize(1);
  if (scanRunning) { tft.setTextColor(ui.accent, ui.bg); tft.setCursor(16, CONTENT_Y + 12); tft.print("Scanning nearby networks..."); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(16, CONTENT_Y + 28); tft.print("This can take a few seconds."); footer("FN BACK"); return; }
  if (!scanDone) { tft.setTextColor(ui.dim, ui.bg); tft.setCursor(16, CONTENT_Y + 12); tft.print("No scan results yet."); tft.setTextColor(ui.accent, ui.bg); tft.setCursor(16, CONTENT_Y + 30); tft.print("Press ENTER to scan."); footer("FN BACK     ENTER SCAN"); return; }
  if (networkCount <= 0) { tft.setTextColor(ILI9341_YELLOW, ui.bg); tft.setCursor(16, CONTENT_Y + 12); tft.print("No networks found."); footer("FN BACK     ENTER RESCAN"); return; }
  tft.setTextColor(ILI9341_GREEN, ui.bg); tft.setCursor(12, CONTENT_Y); tft.printf("Found %d network(s)", networkCount);
  for (int i = 0; i < min(networkCount, 15); i++) { int y = CONTENT_Y + 16 + i * 13; String name = WiFi.SSID(i); if (name.isEmpty()) name = "<hidden>"; if (name.length() > 24) name = name.substring(0, 24); tft.setTextColor(ui.text, ui.bg); tft.setCursor(8, y); tft.printf("%02d %s", i + 1, name.c_str()); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(194, y); tft.printf("%ddB", WiFi.RSSI(i)); tft.setCursor(255, y); tft.print(enc(WiFi.encryptionType(i))); } footer("FN BACK     ENTER RESCAN");
}
void drawNotes() {
  tft.fillScreen(ui.bg); header("NOTES"); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(7, CONTENT_Y); tft.print("Saved in flash memory:"); int first = max(0, noteLine - 16);
  for (int i = first; i < noteCount; i++) { int y = CONTENT_Y + 14 + (i - first) * 11; if (y > H - FOOTER_H - 10) break; bool cur = i == noteLine; if (cur) tft.fillRect(4, y - 1, W - 8, 10, ILI9341_DARKGREY); tft.setTextColor(ui.text, cur ? ILI9341_DARKGREY : ui.bg); tft.setCursor(8, y); tft.print(notes[i]); if (cur) tft.print("_"); } footer("FN BACK  ENTER NEW LINE  DEL ERASE  CTRL+L CLEAR");
}
void updateNotesLine() { int first = max(0, noteLine - 16), y = CONTENT_Y + 14 + (noteLine - first) * 11; if (y > H - FOOTER_H - 10) { redrawNeeded = true; return; } tft.fillRect(4, y - 1, W - 8, 10, ILI9341_DARKGREY); tft.setTextSize(1); tft.setTextColor(ui.text, ILI9341_DARKGREY); tft.setCursor(8, y); tft.print(notes[noteLine]); tft.print("_"); }
void updateClockValue() { tft.fillRect(34, 70, 254, 38, ui.bg); tft.setTextSize(4); tft.setTextColor(ui.accent, ui.bg); tft.setCursor(38, 76); tft.print(uptime()); }
void drawClock() { tft.fillScreen(ui.bg); header("CLOCK"); updateClockValue(); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(56, 128); tft.print("UPTIME CLOCK"); tft.setCursor(25, 151); tft.print("Time begins at boot; no RTC sync."); footer("FN BACK"); }
void updateCalcPanel() { tft.fillRoundRect(10, CONTENT_Y + 20, 300, 43, 4, ILI9341_DARKGREY); String s = calcInput; if (s.length() > 13) s = s.substring(s.length() - 13); tft.setTextSize(3); tft.setTextColor(ui.accent, ILI9341_DARKGREY); tft.setCursor(18, CONTENT_Y + 32); tft.print(s); tft.fillRect(10, CONTENT_Y + 122, 300, 13, ui.bg); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(14, CONTENT_Y + 128); tft.print(calcStatus); }
void drawCalc() { tft.fillScreen(ui.bg); header("CALCULATOR"); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(12, CONTENT_Y + 8); tft.print("Expression:"); updateCalcPanel(); tft.setTextColor(ui.text, ui.bg); tft.setCursor(14, CONTENT_Y + 84); tft.print("Keys: 0-9 .  + - * /"); tft.setCursor(14, CONTENT_Y + 101); tft.print("ENTER = result     DEL = clear"); footer("FN BACK"); }

void drawQRTextField() { tft.fillRoundRect(8, CONTENT_Y + 4, 304, 20, 4, ILI9341_DARKGREY); String shown = qrText; if (shown.length() > 46) shown = shown.substring(shown.length() - 46); tft.setTextSize(1); tft.setTextColor(ui.text, ILI9341_DARKGREY); tft.setCursor(13, CONTENT_Y + 11); tft.print(shown); tft.print("_"); }
void drawQRModules() {
  uint8_t data[qrcode_getBufferSize(5)]; QRCode code; qrcode_initText(&code, data, 5, ECC_LOW, qrText.c_str());
  const int scale = 4, size = code.size * scale, x = (W - size) / 2, y = CONTENT_Y + 31;
  tft.fillRect(0, y, W, size, ui.bg); tft.fillRect(x - 4, y - 4, size + 8, size + 8, ILI9341_WHITE);
  for (int row = 0; row < code.size; ++row) for (int col = 0; col < code.size; ++col) if (qrcode_getModule(&code, col, row)) tft.fillRect(x + col * scale, y + row * scale, scale, scale, ILI9341_BLACK);
}
void drawQRText() { tft.fillScreen(ui.bg); header("QR TEXT"); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, CONTENT_Y - 6); tft.print("TEXT (ASCII, max 60 characters)"); drawQRTextField(); drawQRModules(); footer("FN BACK  ENTER GENERATE  DEL ERASE"); }

int findCVar(const String& name) { for (int i = 0; i < cVarCount; i++) if (cVars[i].name == name) return i; return -1; }
int findCStringVar(const String& name) { for (int i = 0; i < cStringVarCount; i++) if (cStringVars[i].name == name) return i; return -1; }

// C LAB built-ins are refreshed whenever code runs and are deliberately read-only.
bool cBuiltinNumber(const String& name, long& value) {
  if (name == "battery") { int level = M5Cardputer.Power.getBatteryLevel(); value = level < 0 ? -1 : level; return true; }
  if (name == "uptime") { value = millis() / 1000UL; return true; }
  if (name == "free_heap_kb") { value = ESP.getFreeHeap() / 1024UL; return true; }
  if (name == "total_heap_kb") { value = ESP.getHeapSize() / 1024UL; return true; }
  if (name == "flash_mb") { value = ESP.getFlashChipSize() / 1048576UL; return true; }
  if (name == "wifi_connected") { value = WiFi.status() == WL_CONNECTED ? 1 : 0; return true; }
  if (name == "wifi_rssi") { value = WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0; return true; }
  if (name == "wifi_networks") { int result = WiFi.scanComplete(); value = result >= 0 ? result : 0; return true; }
  return false;
}
bool cBuiltinText(const String& name, String& value) {
  if (name == "device_name") { value = "M5Stack Cardputer XL"; return true; }
  if (name == "theme_name") { value = themeNames[themeIndex]; return true; }
  if (name == "uptime_text") { value = uptime(); return true; }
  if (name == "wifi_ssid") { value = WiFi.status() == WL_CONNECTED ? WiFi.SSID() : ""; return true; }
  return false;
}
bool isCBuiltinName(const String& name) { long number; String text; return cBuiltinNumber(name, number) || cBuiltinText(name, text); }

String cTextExpr(String expression, bool& ok) {
  String e = expression; e.trim(); ok = true; String result = "";
  bool inQuotes = false; int start = 0;
  for (int pos = 0; pos <= (int)e.length(); ++pos) {
    if (pos < (int)e.length() && e[pos] == '"') inQuotes = !inQuotes;
    if (pos == (int)e.length() || (!inQuotes && e[pos] == '+')) {
      String part = e.substring(start, pos); part.trim();
      if (part.isEmpty()) { ok = false; return ""; }
      if (part.length() >= 2 && part.charAt(0) == '"' && part.charAt(part.length() - 1) == '"') {
        result += part.substring(1, part.length() - 1);
      } else {
        int stringVar = findCStringVar(part);
        if (stringVar >= 0) result += cStringVars[stringVar].value;
        else { String builtinText; if (cBuiltinText(part, builtinText)) { result += builtinText; start = pos + 1; continue; }
          bool numberOk = true;
          long number = cExpr(part, numberOk);
          if (!numberOk) { ok = false; return ""; }
          result += String(number);
        }
      }
      start = pos + 1;
    }
  }
  if (inQuotes) ok = false;
  return result;
}
long cAtom(String token, bool& ok) { token.trim(); if (token.length() == 0) { ok = false; return 0; } int i = findCVar(token); if (i >= 0) return cVars[i].value; long builtinValue; if (cBuiltinNumber(token, builtinValue)) return builtinValue; char* endPtr; long n = strtol(token.c_str(), &endPtr, 10); if (*endPtr == 0) return n; ok = false; return 0; }
long cExpr(String expression, bool& ok) {
  String e = expression; e.replace(" ", ""); e.replace("\t", ""); ok = true; if (e.isEmpty()) { ok = false; return 0; } int pos = 0;
  auto readAtom = [&](long& value) -> bool { if (pos >= (int)e.length()) return false; int start = pos; if (e[pos] == '-' || e[pos] == '+') pos++; while (pos < (int)e.length() && e[pos] != '+' && e[pos] != '-' && e[pos] != '*' && e[pos] != '/' && e[pos] != '%') pos++; String token = e.substring(start, pos); bool atomOk = true; value = cAtom(token, atomOk); return atomOk; };
  long term = 0; if (!readAtom(term)) { ok = false; return 0; } long total = 0; char addOp = '+';
  while (pos < (int)e.length()) { char op = e[pos++]; long rhs = 0; if (!readAtom(rhs)) { ok = false; return 0; } if (op == '*' || op == '/' || op == '%') { if ((op == '/' || op == '%') && rhs == 0) { ok = false; return 0; } if (op == '*') term *= rhs; else if (op == '/') term /= rhs; else term %= rhs; } else if (op == '+' || op == '-') { total = addOp == '+' ? total + term : total - term; term = rhs; addOp = op; } else { ok = false; return 0; } }
  return addOp == '+' ? total + term : total - term;
}
void cLog(const String& line) { if (cOutputCount < 5) cOutput[cOutputCount++] = line; else { for (int i = 0; i < 4; i++) cOutput[i] = cOutput[i + 1]; cOutput[4] = line; } }

// Local scan only: it never connects, sends credentials, or stores network data.
void cWifiScan() {
  WiFi.mode(WIFI_STA);
  WiFi.disconnect();
  delay(100);
  WiFi.scanDelete();
  int found = WiFi.scanNetworks();
  if (found < 0) { cLog("ERR: Wi-Fi scan failed"); return; }
  cLog("Wi-Fi: " + String(found) + " network(s)");
  for (int i = 0; i < min(found, 4); ++i) {
    String ssid = WiFi.SSID(i);
    if (ssid.isEmpty()) ssid = "<hidden>";
    if (ssid.length() > 20) ssid = ssid.substring(0, 20);
    cLog(ssid + " " + String(WiFi.RSSI(i)) + "dB");
  }
}

// Ctrl+D loads this compact, safe example. Ctrl+Enter runs it.
void loadCLabDemo() {
  const char* demo[] = {
    "// C LAB QUICK DEMO",
    "int x = 7;",
    "x++;",
    "String name = input(\"Your name?\");",
    "void greet() {",
    "  println(\"Hi \" + name + \"! x=\" + x);",
    "}",
    "greet();",
    "println(device_name);",
    "println(\"Battery: \" + battery + \"%\");",
    "println(\"Uptime: \" + uptime_text);",
    "wifi_scan();",
    "println(\"Networks: \" + wifi_networks);",
    "qrcode(device_name + \" / \" + name);"
  };
  cLineCount = sizeof(demo) / sizeof(demo[0]);
  for (int i = 0; i < cLineCount; ++i) cLines[i] = demo[i];
  for (int i = cLineCount; i < C_MAX_LINES; ++i) cLines[i] = "";
  cCursorLine = 0; cCursorColumn = 0; cScrollLine = 0; cHorizontalScroll = 0;
  cOutputCount = 0; cInputActive = false; cInputValueCount = 0; cInputReadIndex = 0;
  cLabQrActive = false; cLabQrPayload = "";
  cLog("[demo loaded: Ctrl+Enter]");
  markStateDirty();
  playFunctionSound();
  redrawNeeded = true;
}

void runCLab() {
  cOutputCount = 0; cVarCount = 0; cStringVarCount = 0; cInputReadIndex = 0; cInputActive = false; bool hasError = false; String source;
  for (int i = 0; i < cLineCount; i++) { String sourceLine = cLines[i]; int comment = sourceLine.indexOf("//"); if (comment >= 0) sourceLine.remove(comment); source += sourceLine; source += '\n'; }
  cFunctionCount = 0; int functionAt = source.indexOf("void ");
  while (functionAt >= 0 && !hasError) {
    int openParen = source.indexOf('(', functionAt + 5), closeParen = openParen < 0 ? -1 : source.indexOf(')', openParen + 1), openBrace = closeParen < 0 ? -1 : source.indexOf('{', closeParen + 1);
    if (openParen < 0 || closeParen < 0 || openBrace < 0) { cLog("ERR: bad void function"); hasError = true; break; }
    String name = source.substring(functionAt + 5, openParen), parameters = source.substring(openParen + 1, closeParen); name.trim(); parameters.trim(); int depth = 1, closeBrace = openBrace + 1;
    while (closeBrace < (int)source.length() && depth > 0) { if (source[closeBrace] == '{') depth++; else if (source[closeBrace] == '}') depth--; closeBrace++; }
    if (depth != 0 || !parameters.isEmpty() || name.isEmpty() || cFunctionCount >= 8) { cLog("ERR: void needs name() { ... }"); hasError = true; break; }
    cFunctions[cFunctionCount++] = {name, source.substring(openBrace + 1, closeBrace - 1)}; source.remove(functionAt, closeBrace - functionAt); functionAt = source.indexOf("void ");
  }
  // If Arduino-style functions are present, run setup() once and loop() once.
  // Plain statements still run directly, so both C LAB styles remain valid.
  for (int i = 0; i < cFunctionCount; ++i) {
    if (cFunctions[i].name == "setup" || cFunctions[i].name == "loop") {
      source += "\n" + cFunctions[i].body + ";";
    }
  }
  int functionCalls = 0, from = 0;
  while (from < (int)source.length() && !hasError) {
    int to = source.indexOf(';', from); if (to < 0) to = source.length(); String line = source.substring(from, to); line.trim(); from = to + 1; if (line.isEmpty()) continue;
    if (line == "clear()") { cOutputCount = 0; continue; } if (line == "help()") { cLog("String x=input(\"name?\"); println(x);"); continue; }
    if (line == "wifi_scan()") { cWifiScan(); continue; }
    if (line.startsWith("String ")) {
      if (cStringVarCount >= 8) { cLog("ERR: max 8 text variables"); hasError = true; continue; }
      String declaration = line.substring(7); declaration.trim(); int eq = declaration.indexOf('='); String name = eq < 0 ? declaration : declaration.substring(0, eq); name.trim();
      if (name.isEmpty() || isCBuiltinName(name) || findCStringVar(name) >= 0 || findCVar(name) >= 0) { cLog("ERR: reserved or bad String name"); hasError = true; continue; }
      String value = "";
      if (eq >= 0) { String initializer = declaration.substring(eq + 1); initializer.trim();
        if (initializer.startsWith("input(") && initializer.endsWith(")")) {
          String promptPart = initializer.substring(6, initializer.length() - 1); bool promptOk; String prompt = cTextExpr(promptPart, promptOk);
          if (!promptOk) { cLog("ERR: input needs text prompt"); hasError = true; continue; }
          if (cInputReadIndex >= cInputValueCount) { cInputActive = true; cInputPrompt = prompt; cInputBuffer = ""; redrawNeeded = true; return; }
          value = cInputValues[cInputReadIndex++];
        } else { bool textOk; value = cTextExpr(initializer, textOk); if (!textOk) { cLog("ERR: bad String value"); hasError = true; continue; } }
      }
      cStringVars[cStringVarCount++] = {name, value};
    }
    else if (line.startsWith("int ")) { if (cVarCount >= 12) { cLog("ERR: max 12 variables"); hasError = true; continue; } String declaration = line.substring(4); declaration.trim(); int eq = declaration.indexOf('='); String name = eq < 0 ? declaration : declaration.substring(0, eq); name.trim(); bool ok = true; long value = eq < 0 ? 0 : cExpr(declaration.substring(eq + 1), ok); if (!ok || name.isEmpty() || isCBuiltinName(name) || findCVar(name) >= 0 || findCStringVar(name) >= 0) { cLog("ERR: reserved or bad int name"); hasError = true; continue; } cVars[cVarCount++] = {name, value}; }
    else if (line.endsWith("++") || line.endsWith("--")) { String name = line.substring(0, line.length() - 2); name.trim(); int var = findCVar(name); if (var < 0) { cLog("ERR: unknown variable"); hasError = true; } else cVars[var].value += line.endsWith("++") ? 1 : -1; }
    else if (line.startsWith("qrcode(")) { int open = line.indexOf('('), close = line.lastIndexOf(')'); if (close <= open) { cLog("ERR: bad qrcode call"); hasError = true; continue; } String argument = line.substring(open + 1, close); bool ok; String text = cTextExpr(argument, ok); if (!ok || text.isEmpty() || text.length() > QR_MAX_TEXT) { cLog("ERR: qrcode needs 1-60 ASCII chars"); hasError = true; } else { bool ascii = true; for (int i = 0; i < (int)text.length(); ++i) if ((uint8_t)text[i] < 32 || (uint8_t)text[i] > 126) ascii = false; if (!ascii) { cLog("ERR: qrcode uses ASCII only"); hasError = true; } else { cLabQrPayload = text; cLabQrActive = true; cLog("[QR ready - FN returns]"); } } }
    else if (line.startsWith("print(") || line.startsWith("println(") || line.startsWith("puts(") || line.startsWith("printf(")) { int open = line.indexOf('('), close = line.lastIndexOf(')'); if (close <= open) { cLog("ERR: bad print call"); hasError = true; continue; } String argument = line.substring(open + 1, close); bool ok; String text = cTextExpr(argument, ok); if (!ok) { cLog("ERR: bad print expression"); hasError = true; } else cLog(text); }
    else if (line.startsWith("hello.world(")) { int open = line.indexOf('('), close = line.lastIndexOf(')'); String argument = close > open ? line.substring(open + 1, close) : ""; argument.trim(); if (!argument.startsWith("\"") || !argument.endsWith("\"") || argument.length() < 2) { cLog("ERR: hello.world needs text"); hasError = true; } else cLog(argument.substring(1, argument.length() - 1)); }
    else if (line.endsWith("()")) { String functionName = line.substring(0, line.length() - 2); functionName.trim(); int functionIndex = -1; for (int i = 0; i < cFunctionCount; i++) if (cFunctions[i].name == functionName) { functionIndex = i; break; } if (functionIndex < 0 || ++functionCalls > 16) { cLog(functionIndex < 0 ? "ERR: unknown function" : "ERR: function depth"); hasError = true; } else source = source.substring(0, from) + cFunctions[functionIndex].body + ";" + source.substring(from); }
    else { int eq = line.indexOf('='); if (eq <= 0) { cLog("ERR: unknown statement"); hasError = true; continue; } String name = line.substring(0, eq); name.trim(); int var = findCVar(name); bool ok; long value = cExpr(line.substring(eq + 1), ok); if (var < 0 || !ok) { cLog("ERR: bad assignment"); hasError = true; } else cVars[var].value = value; }
  }
  if (!hasError) cLog("[done]"); redrawNeeded = true;
}
void drawCLabInput() {
  tft.fillScreen(ui.bg); header("C LAB INPUT"); tft.setTextSize(1); tft.setTextColor(ui.accent, ui.bg); tft.setCursor(12, CONTENT_Y + 12); tft.print(cInputPrompt); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(12, CONTENT_Y + 30); tft.print("Type your answer and press ENTER");
  tft.fillRoundRect(8, CONTENT_Y + 42, 304, 24, 4, ILI9341_DARKGREY); tft.setTextColor(ui.text, ILI9341_DARKGREY); tft.setCursor(14, CONTENT_Y + 50); String shown = cInputBuffer; if (shown.length() > 46) shown = shown.substring(shown.length() - 46); tft.print(shown); tft.print("_"); footer("ENTER CONFIRM     DEL ERASE     FN CANCEL");
}
void drawCLabCodeLine(int lineIndex) {
  if (lineIndex < cScrollLine || lineIndex >= cScrollLine + C_CODE_VISIBLE_LINES) return;
  int row = lineIndex - cScrollLine, codeY = CONTENT_Y + 12, y = codeY + 4 + row * 11; bool active = lineIndex == cCursorLine; uint16_t bg = active ? ui.selected : ILI9341_DARKGREY;
  tft.fillRect(8, y - 1, 304, 10, bg); tft.setTextSize(1); tft.setTextColor(ui.dim, bg); tft.setCursor(10, y); tft.printf("%02d", lineIndex + 1); tft.setTextColor(ui.text, bg); tft.setCursor(30, y); tft.print(cLines[lineIndex].substring(cHorizontalScroll, cHorizontalScroll + C_CODE_COLUMNS_VISIBLE)); if (active) tft.drawFastVLine(30 + (cCursorColumn - cHorizontalScroll) * 6, y - 1, 10, ui.accent);
}
void drawCLabEditor() {
  tft.setTextSize(1); tft.fillRect(0, CONTENT_Y, W, 12, ui.bg); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, CONTENT_Y); tft.printf("CODE L%d-%d/%d  COL %d", cScrollLine + 1, min(cScrollLine + C_CODE_VISIBLE_LINES, cLineCount), cLineCount, cHorizontalScroll + 1);
  const int codeY = CONTENT_Y + 12; tft.fillRoundRect(6, codeY, 308, 91, 4, ILI9341_DARKGREY); for (int row = 0; row < C_CODE_VISIBLE_LINES; row++) { int lineIndex = cScrollLine + row; if (lineIndex >= cLineCount) break; drawCLabCodeLine(lineIndex); }
}
void drawCLabGuide() {
  tft.fillScreen(ui.bg); header("C LAB GUIDE");
  tft.setTextSize(1);
  int y = CONTENT_Y + 3;
  auto guideLine = [&](const char* text, uint16_t color) { tft.setTextColor(color, ui.bg); tft.setCursor(10, y); tft.print(text); y += 12; };
  guideLine("SAFE LOCAL C-STYLE PLAYGROUND", ui.accent);
  guideLine("", ui.text);
  guideLine("EDIT: type text | ENTER: new line | DEL: erase", ui.text);
  guideLine("TAB: navigation mode; ;/. up/down, ,/ left/right", ui.text);
  guideLine("CTRL+ENTER: run code       CTRL+D: load quick demo", ui.text);
  guideLine("", ui.text);
  guideLine("STATEMENTS", ui.accent);
  guideLine("int x = 5;   x++;   x = x * 2 + 1;", ui.text);
  guideLine("String n = input(\"Your name?\");", ui.text);
  guideLine("println(\"Hello \" + n + \"!\");", ui.text);
  guideLine("qrcode(\"hello\");  qrcode(\"Hi \" + n);", ui.text);
  guideLine("System: battery, uptime, free_heap_kb, flash_mb", ui.text);
  guideLine("Wi-Fi: wifi_scan(); wifi_networks, wifi_connected", ui.text);
  guideLine("Wi-Fi text: wifi_ssid; signal: wifi_rssi (dBm)", ui.text);
  guideLine("Text: device_name, theme_name, uptime_text", ui.text);
  guideLine("print(x);  puts(\"text\");  printf(x);", ui.text);
  guideLine("clear();  help();  // comment", ui.text);
  guideLine("", ui.text);
  guideLine("FUNCTIONS", ui.accent);
  guideLine("void hi() { print(\"hello\"); }   hi();", ui.text);
  guideLine("Operators: +  -  *  /  %   (integer values)", ui.dim);
  guideLine("No loops, pointers, files, Wi-Fi connection or GPIO.", ui.dim);
  footer("CTRL+G CLOSE     FN BACK");
}
void drawCLabQR() {
  tft.fillScreen(ui.bg); header("C LAB QR");
  tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, CONTENT_Y - 6); tft.print("qrcode() OUTPUT");
  uint8_t data[qrcode_getBufferSize(5)]; QRCode code; qrcode_initText(&code, data, 5, ECC_LOW, cLabQrPayload.c_str());
  const int scale = 4, size = code.size * scale, x = (W - size) / 2, y = CONTENT_Y + 13;
  tft.fillRect(x - 4, y - 4, size + 8, size + 8, ILI9341_WHITE);
  for (int row = 0; row < code.size; ++row) for (int col = 0; col < code.size; ++col) if (qrcode_getModule(&code, col, row)) tft.fillRect(x + col * scale, y + row * scale, scale, scale, ILI9341_BLACK);
  tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, H - FOOTER_H - 12); String shown = cLabQrPayload; if (shown.length() > 48) shown = shown.substring(0, 48); tft.print(shown);
  footer("FN BACK TO C LAB");
}
void drawCLab() { if (cInputActive) { drawCLabInput(); return; } if (cLabGuideVisible) { drawCLabGuide(); return; } if (cLabQrActive) { drawCLabQR(); return; } tft.fillScreen(ui.bg); header("C LAB"); drawCLabEditor(); int outputY = CONTENT_Y + 111; tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, outputY); tft.print("OUTPUT"); tft.drawFastHLine(6, outputY + 9, 308, ui.dim); for (int i = 0; i < cOutputCount; i++) { tft.setTextColor(i == cOutputCount - 1 && cOutput[i].startsWith("ERR") ? ILI9341_RED : ILI9341_GREEN, ui.bg); tft.setCursor(10, outputY + 15 + i * 11); tft.print(cOutput[i]); } footer(cNavigationMode ? "NAV: ; UP . DOWN , LEFT / RIGHT   CTRL+G GUIDE" : "TAB NAV  CTRL+D DEMO  CTRL+G GUIDE"); }

void updateSettingsRow(int i) { int y = CONTENT_Y + 10 + i * 34; bool sel = i == settingSelected; tft.fillRect(8, y - 5, 304, 27, ui.bg); uint16_t fill = sel ? ui.selected : ui.bg; if (sel) tft.fillRoundRect(8, y - 5, 304, 27, 4, fill); tft.setTextSize(1); tft.setTextColor(ui.text, fill); tft.setCursor(16, y); tft.print(sel ? "> " : "  "); tft.print(settingNames[i]); tft.setTextColor(sel ? ui.accent : ui.dim, fill); tft.setCursor(165, y); tft.print(settingValue(i)); }
String settingValue(int i) { if (i == 0) return themeNames[themeIndex]; if (i == 1) return displayRotation == 3 ? "LANDSCAPE RIGHT" : "LANDSCAPE LEFT"; if (i == 2) return backlightOn ? "ON" : "OFF"; if (i == 3) return String(brightnessLevel * 10) + "%"; if (i == 4) return sleepValues[sleepIndex] == 0 ? "NEVER" : String(sleepValues[sleepIndex]) + " SEC"; return String(volumeLevel * 10) + "%"; }
void drawSettings() { tft.fillScreen(ui.bg); header("SETTINGS"); tft.setTextSize(1); for (int i = 0; i < 6; i++) { int y = CONTENT_Y + 10 + i * 34; bool sel = i == settingSelected; uint16_t fill = sel ? ui.selected : ui.bg; if (sel) tft.fillRoundRect(8, y - 5, 304, 27, 4, fill); tft.setTextColor(ui.text, fill); tft.setCursor(16, y); tft.print(sel ? "> " : "  "); tft.print(settingNames[i]); tft.setTextColor(sel ? ui.accent : ui.dim, fill); tft.setCursor(165, y); tft.print(settingValue(i)); } footer(";/. SELECT     ,/ CHANGE     FN BACK"); }
void draw() { if (page == LAUNCHER) drawLauncher(); else if (page == SYSTEM) drawSystem(); else if (page == WIFI) drawWifi(); else if (page == NOTES) drawNotes(); else if (page == CLOCK) drawClock(); else if (page == CALC) drawCalc(); else if (page == CLAB) drawCLab(); else if (page == QRTEXT) drawQRText(); else drawSettings(); redrawNeeded = false; updateBuiltinDisplay(true); }
void startScan() { if (scanRunning) return; WiFi.mode(WIFI_STA); WiFi.disconnect(); delay(100); WiFi.scanDelete(); WiFi.scanNetworks(true); scanRunning = true; scanDone = false; scanStarted = millis(); redrawNeeded = true; }
void checkScan() { if (!scanRunning) return; int r = WiFi.scanComplete(); if (r >= 0) { networkCount = r; scanRunning = false; scanDone = true; redrawNeeded = true; } else if (millis() - scanStarted > 20000UL) { WiFi.scanDelete(); networkCount = 0; scanRunning = false; scanDone = true; redrawNeeded = true; } }
void calcResult() { float v = calcInput.toFloat(); if (calcOp == 0) calcTotal = v; else if (calcOp == '+') calcTotal += v; else if (calcOp == '-') calcTotal -= v; else if (calcOp == '*') calcTotal *= v; else if (calcOp == '/') { if (v == 0) { calcStatus = "Error: division by zero"; calcInput = "0"; calcOp = 0; calcNew = true; updateCalcPanel(); return; } calcTotal /= v; } calcInput = String(calcTotal, 4); while (calcInput.endsWith("0")) calcInput.remove(calcInput.length() - 1); if (calcInput.endsWith(".")) calcInput.remove(calcInput.length() - 1); calcStatus = "Result"; calcOp = 0; calcNew = true; updateCalcPanel(); }
void changeSetting(int d) { if (settingSelected == 0) { themeIndex = (themeIndex + d + THEME_COUNT) % THEME_COUNT; applyTheme(); } else if (settingSelected == 1) { displayRotation = displayRotation == 3 ? 1 : 3; tft.setRotation(displayRotation); } else if (settingSelected == 2) setBacklight(!backlightOn); else if (settingSelected == 3) { brightnessLevel = (brightnessLevel + d + 9) % 10 + 1; applyBacklight(); } else if (settingSelected == 4) sleepIndex = (sleepIndex + d + 5) % 5; else { volumeLevel = (volumeLevel + d + 11) % 11; applyVolume(); } markStateDirty(); redrawNeeded = true; }
void syncLauncherScroll() { if (appSelected < appScroll) appScroll = appSelected; if (appSelected >= appScroll + APP_VISIBLE) appScroll = appSelected - APP_VISIBLE + 1; appScroll = constrain(appScroll, 0, max(0, APP_COUNT - APP_VISIBLE)); }
void clabBackspace() {
  bool changed = false, merged = false;
  if (cCursorColumn > 0) {
    cLines[cCursorLine].remove(cCursorColumn - 1, 1);
    cCursorColumn--;
    changed = true;
  } else if (cCursorLine > 0) {
    int oldLength = cLines[cCursorLine - 1].length();
    if (oldLength + (int)cLines[cCursorLine].length() <= C_MAX_LINE_CHARS) {
      cLines[cCursorLine - 1] += cLines[cCursorLine];
      for (int i = cCursorLine; i < cLineCount - 1; i++) cLines[i] = cLines[i + 1];
      cLineCount--;
      cLines[cLineCount] = "";
      cCursorLine--;
      cCursorColumn = oldLength;
      if (cCursorLine < cScrollLine) cScrollLine = cCursorLine;
      cHorizontalScroll = max(0, cCursorColumn - C_CODE_COLUMNS_VISIBLE + 1);
      changed = true;
      merged = true;
    }
  }
  if (!changed) return;
  playBackspaceSound();
  int maxHorizontalScroll = max(0, (int)cLines[cCursorLine].length() - C_CODE_COLUMNS_VISIBLE + 1);
  cHorizontalScroll = constrain(cHorizontalScroll, 0, maxHorizontalScroll);
  if (merged) drawCLabEditor(); else drawCLabCodeLine(cCursorLine);
  markStateDirty();
}
void moveCLabCursor(int direction) {
  int oldLine = cCursorLine, oldScroll = cScrollLine;
  if (direction == -2 && cCursorLine > 0) cCursorLine--; else if (direction == 2 && cCursorLine < cLineCount - 1) cCursorLine++; else if (direction == -1 && cCursorColumn > 0) cCursorColumn--; else if (direction == 1 && cCursorColumn < (int)cLines[cCursorLine].length()) cCursorColumn++; else return;
  cCursorColumn = min(cCursorColumn, (int)cLines[cCursorLine].length()); if (cCursorLine < cScrollLine) cScrollLine = cCursorLine; if (cCursorLine >= cScrollLine + C_CODE_VISIBLE_LINES) cScrollLine = cCursorLine - C_CODE_VISIBLE_LINES + 1;
  int maxHorizontalScroll = max(0, (int)cLines[cCursorLine].length() - C_CODE_COLUMNS_VISIBLE + 1);
  if (cCursorColumn < cHorizontalScroll) cHorizontalScroll = cCursorColumn;
  if (cCursorColumn >= cHorizontalScroll + C_CODE_COLUMNS_VISIBLE) cHorizontalScroll = cCursorColumn - C_CODE_COLUMNS_VISIBLE + 1;
  cHorizontalScroll = constrain(cHorizontalScroll, 0, maxHorizontalScroll);
  playCursorSound();
  if (page == CLAB && !sleeping) { if (oldScroll != cScrollLine) drawCLabEditor(); else { drawCLabCodeLine(oldLine); drawCLabCodeLine(cCursorLine); } } else redrawNeeded = true;
}
void handleCLabCursorKeys() {
  static int lastDirection = 0;
  static unsigned long nextMoveAt = 0;
  if (page != CLAB || sleeping || cLabGuideVisible) { lastDirection = 0; return; }
  auto k = M5Cardputer.Keyboard.keysState();
  if (!k.ctrl && !cNavigationMode) { lastDirection = 0; return; }
  int direction = 0;
  if (M5Cardputer.Keyboard.isKeyPressed(';') || M5Cardputer.Keyboard.isKeyPressed('w')) direction = -2;
  else if (M5Cardputer.Keyboard.isKeyPressed('.') || M5Cardputer.Keyboard.isKeyPressed('s')) direction = 2;
  else if (M5Cardputer.Keyboard.isKeyPressed(',') || M5Cardputer.Keyboard.isKeyPressed('a')) direction = -1;
  else if (M5Cardputer.Keyboard.isKeyPressed('/') || M5Cardputer.Keyboard.isKeyPressed('d')) direction = 1;
  if (direction == 0) { lastDirection = 0; return; }
  unsigned long now = millis();
  if (direction != lastDirection || now >= nextMoveAt) {
    bool firstPress = direction != lastDirection;
    moveCLabCursor(direction);
    lastDirection = direction;
    nextMoveAt = now + (firstPress ? 180UL : 70UL);
  }
}

// Helper: check if a char exists in the std::vector<char> word list
static bool wordContains(const std::vector<char>& word, char c) {
  for (char ch : word) { if (ch == c) return true; }
  return false;
}

void keyboard() {
  auto k = M5Cardputer.Keyboard.keysState(); bool event = M5Cardputer.Keyboard.isChange(), fn = k.fn;
  if (sleeping) { if (event || fn != fnLast) { sleeping = false; setBacklight(true); lastActivity = millis(); redrawNeeded = true; } fnLast = fn; return; }
  if (event || fn != fnLast) lastActivity = millis(); if (fn && !fnLast && page != LAUNCHER) { playExitSound(); if (page == CLAB && cInputActive) { cInputActive = false; cInputValueCount = 0; cInputReadIndex = 0; cInputBuffer = ""; page = LAUNCHER; } else if (page == CLAB && cLabGuideVisible) cLabGuideVisible = false; else if (page == CLAB && cLabQrActive) cLabQrActive = false; else page = LAUNCHER; redrawNeeded = true; } fnLast = fn;
  if (!event || !M5Cardputer.Keyboard.isPressed()) return;
  if (k.ctrl || k.shift || k.alt) playFunctionSound();
  if (page == CLAB && k.tab && !cLabGuideVisible) { playTabSound(); cNavigationMode = !cNavigationMode; redrawNeeded = true; return; }
  bool ctrlG = k.ctrl && (wordContains(k.word, 'g') || wordContains(k.word, 'G') || M5Cardputer.Keyboard.isKeyPressed('g') || M5Cardputer.Keyboard.isKeyPressed('G'));
  if (page == CLAB && ctrlG) { cLabGuideVisible = !cLabGuideVisible; playFunctionSound(); redrawNeeded = true; return; }
  if (page == CLAB && cLabGuideVisible) return;
  if (page == CLAB && cInputActive) {
    if (k.enter) { if (cInputValueCount < 4) cInputValues[cInputValueCount++] = cInputBuffer; playEnterSound(); runCLab(); return; }
    if (k.del) { if (!cInputBuffer.isEmpty()) { cInputBuffer.remove(cInputBuffer.length() - 1); playBackspaceSound(); drawCLabInput(); } return; }
    bool changed = false; for (char c : k.word) if (c >= 32 && c <= 126 && cInputBuffer.length() < 48) { cInputBuffer += c; changed = true; }
    if (changed) { playTypingSound(); drawCLabInput(); } return;
  }
  bool ctrlD = k.ctrl && (wordContains(k.word, 'd') || wordContains(k.word, 'D') || M5Cardputer.Keyboard.isKeyPressed('d') || M5Cardputer.Keyboard.isKeyPressed('D'));
  if (page == CLAB && ctrlD) { loadCLabDemo(); return; }
  bool ctrlL = k.ctrl && (wordContains(k.word, 'l') || wordContains(k.word, 'L') || M5Cardputer.Keyboard.isKeyPressed('l') || M5Cardputer.Keyboard.isKeyPressed('L'));
  if (ctrlL) { if (page == NOTES) { for (int i = 0; i < 15; ++i) notes[i] = ""; noteLine = 0; noteCount = 1; markStateDirty(); playFunctionSound(); redrawNeeded = true; return; } if (page == CLAB) { for (int i = 0; i < C_MAX_LINES; ++i) cLines[i] = ""; cLineCount = 1; cCursorLine = cCursorColumn = cScrollLine = cHorizontalScroll = 0; cOutputCount = 0; markStateDirty(); playFunctionSound(); redrawNeeded = true; return; } }
  if (page == CLAB && k.ctrl) { if (k.enter) { cInputValueCount = 0; cLabQrActive = false; cLabQrPayload = ""; playEnterSound(); runCLab(); } return; }
  if (page == LAUNCHER) { if (k.enter) { playMenuSound(); page = static_cast<Page>(appSelected + 1); redrawNeeded = true; return; } for (char c : k.word) { if (c == ',' || c == ';') { int oldSelected = appSelected, oldScroll = appScroll; appSelected = (appSelected + APP_COUNT - 1) % APP_COUNT; syncLauncherScroll(); playMenuSound(); updateLauncherSelection(oldSelected, oldScroll); } else if (c == '/' || c == '.') { int oldSelected = appSelected, oldScroll = appScroll; appSelected = (appSelected + 1) % APP_COUNT; syncLauncherScroll(); playMenuSound(); updateLauncherSelection(oldSelected, oldScroll); } } return; }
  if (page == SYSTEM) { if (k.enter) { playMenuSound(); updateSystemValues(); } return; }
  if (page == WIFI) { if (k.enter) { playMenuSound(); startScan(); } return; }
  if (page == CLOCK) return;
  if (page == QRTEXT) { if (k.enter) { playEnterSound(); markStateDirty(); drawQRModules(); return; } if (k.del) { if (!qrText.isEmpty()) { qrText.remove(qrText.length() - 1); markStateDirty(); playBackspaceSound(); drawQRTextField(); } return; } for (char c : k.word) if (c >= 32 && c <= 126 && qrText.length() < QR_MAX_TEXT) { qrText += c; markStateDirty(); playTypingSound(); drawQRTextField(); } return; }
  if (page == CLAB) {
    if (cNavigationMode) return;
    if (k.enter) { playEnterSound(); if (cLineCount < C_MAX_LINES) { String tail = cLines[cCursorLine].substring(cCursorColumn); cLines[cCursorLine].remove(cCursorColumn); for (int i = cLineCount; i > cCursorLine + 1; i--) cLines[i] = cLines[i - 1]; cLines[cCursorLine + 1] = tail; cLineCount++; cCursorLine++; cCursorColumn = 0; cHorizontalScroll = 0; markStateDirty(); if (cCursorLine >= cScrollLine + C_CODE_VISIBLE_LINES) cScrollLine++; drawCLabEditor(); } return; }
    if (k.del) return;
    bool codeChanged = false; for (char c : k.word) if (c >= 32 && c <= 126 && (int)cLines[cCursorLine].length() < C_MAX_LINE_CHARS) { cLines[cCursorLine] = cLines[cCursorLine].substring(0, cCursorColumn) + c + cLines[cCursorLine].substring(cCursorColumn); cCursorColumn++; codeChanged = true; }
      if (codeChanged) { int maxHorizontalScroll = max(0, (int)cLines[cCursorLine].length() - C_CODE_COLUMNS_VISIBLE + 1); if (cCursorColumn >= cHorizontalScroll + C_CODE_COLUMNS_VISIBLE) cHorizontalScroll = cCursorColumn - C_CODE_COLUMNS_VISIBLE + 1; cHorizontalScroll = constrain(cHorizontalScroll, 0, maxHorizontalScroll); playTypingSound(); drawCLabEditor(); markStateDirty(); } return;
  }
  if (page == NOTES) { if (k.enter) { if (noteLine < 14) { if (noteLine == noteCount - 1) noteCount++; noteLine++; markStateDirty(); redrawNeeded = true; } return; } if (k.del) { if (!notes[noteLine].isEmpty()) { notes[noteLine].remove(notes[noteLine].length() - 1); playTypingSound(); } else if (noteLine > 0) { noteLine--; noteCount--; } markStateDirty(); updateNotesLine(); return; } for (char c : k.word) if (c >= 32 && c <= 126 && notes[noteLine].length() < 50) { notes[noteLine] += c; playTypingSound(); markStateDirty(); updateNotesLine(); } return; }
  if (page == CALC) { if (k.enter) { playMenuSound(); calcResult(); return; } if (k.del) { calcInput = "0"; calcTotal = 0; calcOp = 0; calcNew = true; calcStatus = "Cleared"; updateCalcPanel(); return; } for (char c : k.word) { if ((c >= '0' && c <= '9') || c == '.') { if (calcNew) { calcInput = ""; calcNew = false; } if (calcInput.length() < 16 && !(c == '.' && calcInput.indexOf('.') >= 0)) { calcInput += c; playTypingSound(); updateCalcPanel(); } } else if (c == '+' || c == '-' || c == '*' || c == '/') { if (calcOp != 0 && !calcNew) calcResult(); calcTotal = calcInput.toFloat(); calcOp = c; calcNew = true; calcStatus = String("Pending operator: ") + c; playMenuSound(); updateCalcPanel(); } } return; }
  if (page == SETTINGS) for (char c : k.word) { if (c == ';') { int old = settingSelected; settingSelected = (settingSelected + 5) % 6; playMenuSound(); updateSettingsRow(old); updateSettingsRow(settingSelected); } else if (c == '.') { int old = settingSelected; settingSelected = (settingSelected + 1) % 6; playMenuSound(); updateSettingsRow(old); updateSettingsRow(settingSelected); } else if (c == ',') { playMenuSound(); changeSetting(-1); } else if (c == '/') { playMenuSound(); changeSetting(1); } }
}
void setup() {
  auto cfg = M5.config(); M5Cardputer.begin(cfg, true); loadPersistentState(); applyVolume();
  ledcSetup(BACKLIGHT_PWM_CHANNEL, BACKLIGHT_PWM_HZ, BACKLIGHT_PWM_BITS);
  ledcAttachPin(TFT_BL, BACKLIGHT_PWM_CHANNEL);
  applyBacklight();
  SPI.begin(TFT_SCK, TFT_MISO, TFT_MOSI, TFT_CS); tft.begin(); tft.setRotation(displayRotation); tft.setTextWrap(false); applyTheme(); drawBootScreen(); lastActivity = millis(); draw();
}
void loop() {
  M5Cardputer.update(); checkScan(); keyboard();
  auto keys = M5Cardputer.Keyboard.keysState();
  bool clabDeleteNow = page == CLAB && !sleeping && M5Cardputer.Keyboard.isPressed() && keys.del;
  if (clabDeleteNow) {
    unsigned long now = millis();
    if (!clabDeleteHeld) {
      clabBackspace();
      clabDeleteHeld = true;
      clabDeleteRepeatAt = now + 500UL;
    } else if (now >= clabDeleteRepeatAt) {
      clabBackspace();
      clabDeleteRepeatAt = now + 90UL;
    }
  } else {
    clabDeleteHeld = false;
  }
  handleCLabCursorKeys(); uint16_t timeout = sleepValues[sleepIndex];
  if (timeout && !sleeping && millis() - lastActivity >= (unsigned long)timeout * 1000UL) { sleeping = true; setBacklight(false); }
  static unsigned long lastClock = 0; if (page == CLOCK && !sleeping && millis() - lastClock >= 1000UL) { lastClock = millis(); updateClockValue(); }
  static unsigned long lastBuiltinCheck = 0;
  if (!sleeping && millis() - lastBuiltinCheck >= 250UL) { lastBuiltinCheck = millis(); updateBuiltinDisplay(); }
  if (redrawNeeded && !sleeping) draw(); if (stateDirty && millis() - stateChangedAt >= 2000UL) savePersistentState();
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik