My posts

Building a Modular Starter Kit for M5StickC-Plus2

Arduino C++

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

Illustration of a M5 stick c-plus 2

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);

### 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();
}

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
};

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
}

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
        }
    }
};

Now submenus just work:

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

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
});

But I got errors:

error: no suitable conversion from lambda to void (*)()

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

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

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

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

Now this works:

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

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");

I created the DisplayHandler to abstract positions:

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

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);
}

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();

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();
}

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)

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

What I’d Do Differently

1. Start with std::function

Don’t use C-style function pointers for callbacks. Go straight to std::function<void()>. 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