Building a Modular Starter Kit for M5StickC-Plus2: From Messy Code to Clean Architecture

Published: (December 14, 2025 at 04:35 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Why Another M5Stack Project?

When I first got my M5StickC‑Plus2, I was excited to build something cool.
But like many developers, I quickly hit a wall of boring setup work: configuring buttons, managing display coordinates, handling menus, dealing with power management, setting up timers… Before I could start on the fun part, I had written hundreds of lines of infrastructure code.

Problems with Existing Libraries

  • Black boxes – you can’t see or modify the internals.
  • Over‑abstraction – sometimes you need fine‑grained control.
  • Learning curve – each library has its own API.
  • Dependencies – one library pulls in many others.

What you get

  • ✅ Full source code you can read and understand
  • ✅ Working examples you can modify
  • ✅ Architectural patterns you can extend
  • ✅ No hidden dependencies or magic

If you don’t like how the menu scrolling works? Change it. Want different colors? Modify the display handler. Need a different button layout? Update the controls.

Switching to VSCode + PlatformIO

I started in the Arduino IDE (as many do) but quickly switched to VSCode + PlatformIO. The reasons:

// Where is this function defined?
// Which library does this come from?
// Good luck finding it...
M5.Lcd.setCursor(10, 80);
  • IntelliSense – auto‑completion that actually works
  • Go to Definition – jump to any function’s source
  • Project Structure – proper file organization (the biggest issue for me)
  • Library Management – clear dependency handling
  • Modern C++ – full C++11/14/17 support

The switch took an hour and saved me dozens of hours afterward.

Page Management

Early on I needed multiple “screens” or “pages”. The naïve approach quickly became messy:

// ❌ The messy way
if (currentPage == 0) {
    drawClock();
} else if (currentPage == 1) {
    drawMenu();
} else if (currentPage == 2) {
    drawSettings();
}

Introducing PageManager

class PageBase {
public:
    virtual void setup()   = 0;   // Called when entering page
    virtual void loop()    = 0;   // Called every frame
    virtual void cleanup() = 0;   // Called when leaving page
};

Each page now manages itself:

void ClockPage::setup() {
    display->clearScreen();
    clockHandler->drawClock(0);
}

void ClockPage::loop() {
    if (hasActiveMenu()) return;   // Pause if menu is open
    // Update clock every second
}

Lesson: Give each component its own lifecycle. Don’t manage everything from main().

Menus were surprisingly hard. I needed:

  • A main menu
  • Submenus (e.g., Settings → Display Settings → Brightness)
  • A “back” button that works correctly

Stack‑based MenuManager

class MenuManager {
private:
    MenuHandler* menuStack[MAX_MENU_STACK];
    int stackSize = 0;

public:
    void pushMenu(MenuHandler* menu) {
        menuStack[stackSize++] = menu;
        menu->draw();
    }

    void popMenu() {
        if (stackSize == 0) return;
        --stackSize;
        if (stackSize > 0) {
            menuStack[stackSize - 1]->draw();   // Redraw previous menu
        }
    }
};

Resulting navigation:

Clock Page
  → Open Menu
    → Settings
      → Display
        → [Back]
      → [Back]
    → [Back]
Clock Page (restored)

Lesson: Choose the right data structure. A stack naturally handles nested navigation.

Callbacks with std::function

I wanted menu callbacks that could access class members:

// Desired:
mainMenu->addItem("Start Timer", [this]() {
    clockHandler->startTimer();   // Access class member
});

The old MenuItem used a C‑style function pointer, which cannot capture this:

// ❌ Old way
struct MenuItem {
    void (*callback)();   // Can't capture 'this'!
};

Modern C++ fix

// ✅ New way
#include <functional>

struct MenuItem {
    std::function<void()> callback;   // Can capture anything!
};

Now the lambda works:

mainMenu->addItem("Settings", [this]() {
    openSettingsSubmenu();   // 'this' captured, works perfectly
});

Lesson: Use std::function for callbacks in modern C++. It’s flexible and handles lambdas with captures.

Display Abstraction

Early code was low‑level and repetitive:

// ❌ What does this even mean?
M5.Lcd.setCursor(10, 80);
M5.Lcd.setTextSize(3);
M5.Lcd.print("Hello");

I introduced a DisplayHandler to provide semantic methods:

display->displayMainTitle("Hello");
display->displaySubtitle("Subtitle");
display->displayStatus("Ready", MSG_SUCCESS);

Implementation example:

void displayMainTitle(const char* text, MessageType type) {
    M5.Lcd.setTextSize(SIZE_TITLE);
    M5.Lcd.setTextColor(getColorForType(type));

    int x = (SCREEN_WIDTH - textWidth) / 2;   // Auto‑center
    int y = ZONE_CENTER_Y - 20;

    M5.Lcd.setCursor(x, y);
    M5.Lcd.print(text);
}

Lesson: Abstract low‑level details. Your future self will thank you.

Deep‑Sleep Power Management

The M5StickC‑Plus2 can achieve great battery life—if you use deep sleep correctly.

Broken attempt

// ❌ Crashes on wake‑up
esp_deep_sleep_start();

The issue: GPIO4 must stay HIGH during sleep, otherwise the device loses power.

Working solution

void M5deepSleep(uint64_t microseconds) {
    // CRITICAL: Keep power pin high
    pinMode(4, OUTPUT);
    digitalWrite(4, HIGH);
    gpio_hold_en(GPIO_NUM_4);
    gpio_deep_sleep_hold_en();

    esp_sleep_enable_timer_wakeup(microseconds);
    esp_deepSleep_start();
}

This powers a Pomodoro timer: 25 minutes of sleep, wake up, beep alarm, show clock.

Lesson: Hardware‑specific quirks require hardware‑specific solutions. Don’t assume standard APIs work out of the box.

Button Layout

The M5StickC‑Plus2 has three buttons:

      ______PWR          (side)
                    A    (front)
      ___B_____          (side, opposite)

After testing different layouts, I settled on:

StatePWRAB (short)B (long)
No menuChange pageOpen menuPage‑specific action (e.g., start timer)
Menu activeNavigate downSelect itemNavigate upClose menu

Why this layout?

  • Side buttons for navigation: easier to press while holding the device
  • Center button for actions: most important button in prime position
  • Long press for “back”: prevents accidental exits

Lesson: Button ergonomics matter. Test on actual hardware, not just in your head.

Project Structure

lib/
├── display_handler.h      # Display abstraction
├── menu_handler.h         # Individual menu logic
├── menu_manager.h         # Menu stack
├── page_manager.h         # Page lifecycle
├── clock_handler.h        # Time & timers
├── battery_handler.h      # Power management
└── pages/
    ├── page_base.h        # Abstract base class
    └── clock_page.h       # Default clock page

Naming conventions

  • *_handler.h – focused, reusable components
  • *_manager.h – complex orchestration
  • *_utils.h – utility functions

Every page receives a DisplayHandler* and MenuManager*. They compose functionality rather than inherit display logic.

Final Thoughts

  • Avoid C‑style function pointers for callbacks; use std::function.
  • Test power management early—it’s hardware‑dependent and can break everything.
  • Sketch the button layout before writing code; changing it later ripples through the whole project.
  • Start with VSCode + PlatformIO; the migration pays off quickly.

The complete starter kit is available on GitHub. Clone it, upload to your M5StickC‑Plus2, and start building!

Back to Blog

Related posts

Read more »

Decisões demais, estratégia de menos

!Cover image for Decisões demais, estratégia de menoshttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2F...