Building a Modular Starter Kit for M5StickC-Plus2: From Messy Code to Clean Architecture
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().
Menu Management with a Stack
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:
| State | PWR | A | B (short) | B (long) |
|---|---|---|---|---|
| No menu | Change page | Open menu | Page‑specific action (e.g., start timer) | – |
| Menu active | Navigate down | Select item | Navigate up | Close 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!