DEV Community

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

Posted on • Originally published at christopherdebray.com

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

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.

You know the drill: configuring buttons, managing display coordinates, handling menus, dealing with power management, setting up timers. Before I could even start on the fun part of my project, I had to write hundreds of lines of infrastructure code.

Libraries help, but they come with their own problems:

  • Black boxes: You can't see or modify how they work internally
  • Over-abstraction: Sometimes you need fine-grained control
  • Learning curve: Each library has its own API to learn
  • Dependencies: One library pulls in five others

I wanted something different: a starter kit where you own all the code.

The Philosophy: A Foundation, Not a Framework

This project isn't a library you import. It's a starting point you customize.

Think of it like this:

  • Library: "Here's a menu system, use these methods"
  • This starter: "Here's how I built a menu system, change whatever you want"

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.

The Journey: From Arduino IDE to PlatformIO

I started this project in Arduino IDE (as many do), but quickly switched to VSCode + PlatformIO. Here's why:

Arduino IDE Pain Points

// Where is this function defined?
// Which library does this come from?
// Good luck finding it...
M5.Lcd.setCursor(10, 80);
Enter fullscreen mode Exit fullscreen mode

 PlatformIO Wins

  • 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. It saved me dozens of hours afterward.

Key Architecture Decisions

 1. The Page System: Lifecycle Management

Early on, I realized I needed multiple "screens" or "pages". But switching between them was messy:

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

I needed a proper lifecycle. Enter the PageManager:

class PageBase {
    virtual void setup() = 0;    // Called when entering page
    virtual void loop() = 0;     // Called every frame
    virtual void cleanup() = 0;  // Called when leaving page
};
Enter fullscreen mode Exit fullscreen mode

Now each page 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
}
Enter fullscreen mode Exit fullscreen mode

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

2. The Menu Stack: Nested Menus Done Right

Menus were surprisingly hard. I wanted:

  • A main menu
  • Submenus (Settings → Display Settings → Brightness)
  • A "back" button that works correctly

The solution? A stack (Last In, First Out):

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

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

    void popMenu() {
        stackSize--;
        if (stackSize > 0) {
            menuStack[stackSize - 1]->draw();  // Redraw previous menu
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

Now submenus just work:

Clock Page
  → Open Menu
    → Settings
      → Display
        → [Back]
      → [Back]
    → [Back]
  Clock Page (restored)
Enter fullscreen mode Exit fullscreen mode

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

 3. The Pointer Function vs std::function Saga

This was a 4-hour debugging session that taught me a crucial C++ lesson.

I wanted menu callbacks that could access class members:

// I wanted to do this:
mainMenu->addItem("Start Timer", [this]() {
    clockHandler->startTimer();  // Access class member
});
Enter fullscreen mode Exit fullscreen mode

But I got errors:

error: no suitable conversion from lambda to void (*)()
Enter fullscreen mode Exit fullscreen mode

The problem? My MenuItem struct used old C-style function pointers:

// ❌ Old way (C-style)
struct MenuItem {
    void (*callback)();  // Can't capture 'this'!
};
Enter fullscreen mode Exit fullscreen mode

The fix? Modern C++ std::function:

// ✅ New way (C++11)
struct MenuItem {
    std::function<void()> callback;  // Can capture anything!
};
Enter fullscreen mode Exit fullscreen mode

Now this works:

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

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

4. Display Positioning: No More Magic Numbers

Early code looked like this:

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

I created the DisplayHandler to abstract positions:

// ✅ Semantic and clear
display->displayMainTitle("Hello");
display->displaySubtitle("Subtitle");
display->displayStatus("Ready", MSG_SUCCESS);
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

void displayMainTitle(const char* text, MessageType type) {
    M5.Lcd.setTextSize(SIZE_TITLE);  // Consistent size
    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);
}
Enter fullscreen mode Exit fullscreen mode

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

5. Deep Sleep: The 3-Hour Power Management Bug

The M5StickC-Plus2 has great battery life... if you use deep sleep correctly.

My first attempt:

// ❌ This crashes the device on wake-up
esp_deep_sleep_start();
Enter fullscreen mode Exit fullscreen mode

After diving into documentation and forums, I found the issue: GPIO4 must stay HIGH during sleep or the device loses power.

The 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_deep_sleep_start();
}
Enter fullscreen mode Exit fullscreen mode

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

Lesson learned: Hardware-specific quirks require hardware-specific solutions. Don't always assume standard APIs work out of the box.

Button Controls: Finding the Ergonomic Sweet Spot

The M5StickC-Plus2 has three buttons:

      ______PWR          (side)
                    A    (front)
      ___B_____          (side, opposite)
Enter fullscreen mode Exit fullscreen mode

After testing different layouts, I settled on:

No menu active:

  • PWR: Change page
  • A: Open menu
  • B: Page-specific action (e.g., start timer on double click)

Menu active:

  • PWR: Navigate down
  • A: Select item
  • B (short): Navigate up
  • B (long hold): Close menu

Why this layout?

  • Side buttons for navigation: Easier to press while holding device
  • Center button for actions: Most important button in prime position
  • Long press for "back": Prevents accidental exits

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

 The Tech Stack

  • Platform: M5StickC-Plus2
  • IDE: VSCode + PlatformIO
  • Language: C++ (C++11 features)
  • Libraries: M5Unified
  • Architecture: OOP with composition pattern
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
Enter fullscreen mode Exit fullscreen mode

What I'd Do Differently

1. Start with std::function

Don't use C-style function pointers for callbacks. Go straight to std::function.
Althought it is more consuming than the pointers, but i didn’t have time to figure a better option (for now)

2. Test Deep Sleep Early

Don't wait until the end to test power management. It's hardware-dependent and can break everything.

3. Design Button Layout on Paper

Sketch the button layout before writing code. Changing it later affects everything.

4. Use PlatformIO from Day 1

Don't start in Arduino IDE. The migration takes time and breaks things.

 What Worked Really Well

 1. Composition Over Inheritance

Every page gets a DisplayHandler* and MenuManager*. They don't inherit display logic—they compose it.

 2. Clear Separation of Concerns

*_handler.h: Focused, reusable components

*_manager.h: Complex orchestration

*_utils.h: Utility functions

 3. Lambda Callbacks

Being able to write [this]() { myMethod(); } inline makes code so much cleaner than separate callback functions.

 4. The Base Page Pattern

Every page inherits from PageBase, which provides menu management for free. No duplicate code.

 Try It Yourself

The complete starter kit is available on GitHub. Clone it, upload to your M5StickC-Plus2, and you'll have:

  • ✅ A working clock page
  • ✅ Battery indicator
  • ✅ Menu system with submenus
  • ✅ Page navigation
  • ✅ Pomodoro timer
  • ✅ All the code to modify

Want to build a fitness tracker? Keep the page system, replace the clock logic.

Building a game? Use the menu system for your settings, swap in your game loop.

Creating an IoT dashboard? The display handler abstracts all the positioning for you.

 Final Thoughts

Building this starter kit taught me that good architecture is invisible. When it works, you don't think about pages or menus—you just build features.

That's the goal: give you the boring stuff so you can focus on the interesting stuff.

The M5StickC-Plus2 is a fantastic device. With the right foundation, you can build something amazing in a weekend instead of spending that weekend setting up infrastructure.

Now go build something cool. 🚀

Resources

Top comments (0)