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. 🚀