M5StickC-Plus2용 모듈형 스타터 키트 구축: 지저분한 코드에서 클린 아키텍처로
Source: Dev.to
왜 또 다른 M5Stack 프로젝트인가?
M5StickC‑Plus2를 처음 손에 넣었을 때, 뭔가 멋진 것을 만들고 싶었습니다.
하지만 많은 개발자들처럼, 금방 지루한 초기 설정 작업에 부딪혔습니다: 버튼 설정, 디스플레이 좌표 관리, 메뉴 처리, 전원 관리, 타이머 설정… 재미있는 부분을 시작하기 전에 이미 수백 줄의 인프라 코드를 작성하고 말았습니다.
기존 라이브러리의 문제점
- 블랙 박스 – 내부를 볼 수도 수정할 수도 없습니다.
- 과도한 추상화 – 때때로 세밀한 제어가 필요합니다.
- 학습 곡선 – 각 라이브러리가 자체 API를 가집니다.
- 의존성 – 하나의 라이브러리가 여러 다른 라이브러리를 끌어옵니다.
얻을 수 있는 것
- ✅ 읽고 이해할 수 있는 전체 소스 코드
- ✅ 수정 가능한 동작 예제
- ✅ 확장 가능한 아키텍처 패턴
- ✅ 숨겨진 의존성이나 마법이 없습니다
메뉴 스크롤 방식이 마음에 안 든다면? 바꾸세요. 색상이 다르고 싶다면? 디스플레이 핸들러를 수정하세요. 버튼 레이아웃을 바꾸고 싶다면? 컨트롤을 업데이트하세요.
VSCode + PlatformIO 로 전환
저는 Arduino IDE(많은 사람들이 그렇듯)에서 시작했지만 곧 VSCode + PlatformIO 로 전환했습니다. 이유는 다음과 같습니다:
// Where is this function defined?
// Which library does this come from?
// Good luck finding it...
M5.Lcd.setCursor(10, 80);
- IntelliSense – 실제로 동작하는 자동 완성
- Go to Definition – 함수 정의로 바로 이동
- Project Structure – 올바른 파일 조직(제가 가장 큰 문제였음)
- Library Management – 명확한 의존성 관리
- Modern C++ – 완전한 C++11/14/17 지원
전환에 한 시간 정도 걸렸고, 이후 수십 시간을 절약했습니다.
페이지 관리
초기에 여러 “스크린” 혹은 “페이지”가 필요했습니다. 순진한 접근 방식은 금방 엉망이 되었습니다:
// ❌ The messy way
if (currentPage == 0) {
drawClock();
} else if (currentPage == 1) {
drawMenu();
} else if (currentPage == 2) {
drawSettings();
}
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
};
이제 각 페이지가 스스로를 관리합니다:
void ClockPage::setup() {
display->clearScreen();
clockHandler->drawClock(0);
}
void ClockPage::loop() {
if (hasActiveMenu()) return; // Pause if menu is open
// Update clock every second
}
교훈: 각 컴포넌트에 자체 생명 주기를 부여하세요. main() 에서 모든 것을 관리하지 마세요.
스택 기반 메뉴 관리
메뉴는 생각보다 어려웠습니다. 필요했던 것은:
- 메인 메뉴
- 서브 메뉴(예: 설정 → 디스플레이 설정 → 밝기)
- 올바르게 동작하는 “뒤로” 버튼
스택 기반 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
}
}
};
결과적인 네비게이션 흐름:
Clock Page
→ Open Menu
→ Settings
→ Display
→ [Back]
→ [Back]
→ [Back]
Clock Page (restored)
교훈: 올바른 자료구조를 선택하세요. 스택은 중첩된 네비게이션을 자연스럽게 처리합니다.
std::function 으로 콜백 구현
클래스 멤버에 접근할 수 있는 메뉴 콜백이 필요했습니다:
// Desired:
mainMenu->addItem("Start Timer", [this]() {
clockHandler->startTimer(); // Access class member
});
예전 MenuItem 은 C‑스타일 함수 포인터를 사용했으며, this 를 캡처할 수 없었습니다:
// ❌ Old way
struct MenuItem {
void (*callback)(); // Can't capture 'this'!
};
현대 C++ 해결책
// ✅ New way
#include <functional>
struct MenuItem {
std::function<void()> callback; // Can capture anything!
};
이제 람다식이 정상 동작합니다:
mainMenu->addItem("Settings", [this]() {
openSettingsSubmenu(); // 'this' captured, works perfectly
});
교훈: 현대 C++에서는 콜백에 std::function 을 사용하세요. 람다 캡처를 자유롭게 처리할 수 있습니다.
디스플레이 추상화
초기 코드는 저수준이며 반복적이었습니다:
// ❌ What does this even mean?
M5.Lcd.setCursor(10, 80);
M5.Lcd.setTextSize(3);
M5.Lcd.print("Hello");
DisplayHandler 를 도입해 의미 있는 메서드를 제공했습니다:
display->displayMainTitle("Hello");
display->displaySubtitle("Subtitle");
display->displayStatus("Ready", MSG_SUCCESS);
구현 예시:
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);
}
교훈: 저수준 세부 사항을 추상화하세요. 미래의 자신이 고마워합니다.
딥슬립 전원 관리
M5StickC‑Plus2는 딥슬립을 올바르게 사용하면 배터리 수명을 크게 늘릴 수 있습니다.
실패한 시도
// ❌ Crashes on wake‑up
esp_deep_sleep_start();
문제: GPIO4 가 슬립 중에 HIGH 상태를 유지해야 하는데, 그렇지 않으면 전원이 꺼집니다.
정상 동작 솔루션
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();
}
이 코드는 포모도로 타이머에 사용됩니다: 25분 슬립 → 깨기 → 알람 울리기 → 시계 표시.
교훈: 하드웨어 특유의 트릭은 하드웨어 특화된 해결책이 필요합니다. 표준 API 가 바로 동작한다고 가정하지 마세요.
버튼 레이아웃
M5StickC‑Plus2에는 세 개의 버튼이 있습니다:
______PWR (side)
A (front)
___B_____ (side, opposite)
여러 레이아웃을 시험한 뒤 최종 선택은 다음과 같습니다:
| 상태 | PWR | A | B (짧게) | B (길게) |
|---|---|---|---|---|
| 메뉴 없음 | 페이지 전환 | 메뉴 열기 | 페이지 전용 동작(예: 타이머 시작) | – |
| 메뉴 활성 | 아래로 이동 | 선택 | 위로 이동 | 메뉴 닫기 |
왜 이런 레이아웃인가?
- 측면 버튼은 네비게이션에 적합: 기기를 잡고 있을 때 누르기 쉬움
- 중앙 버튼은 액션에 사용: 가장 중요한 버튼이 최적 위치에 배치
- 길게 누르면 “뒤로”: 실수로 나가는 것을 방지
교훈: 버튼 인체공학은 중요합니다. 실제 하드웨어에서 테스트하세요.
프로젝트 구조
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
네이밍 규칙
*_handler.h– 집중된 재사용 가능한 컴포넌트*_manager.h– 복합적인 오케스트레이션*_utils.h– 유틸리티 함수
각 페이지는 DisplayHandler* 와 MenuManager* 를 전달받습니다. 이들은 상속이 아니라 구성을 통해 기능을 결합합니다.
마무리 생각
- 콜백에 C‑스타일 함수 포인터는 피하고
std::function을 사용하세요. - 전원 관리는 초기에 테스트하세요—하드웨어 의존성이 크고 전체가 깨질 수 있습니다.
- 버튼 레이아웃을 먼저 스케치하고 코딩을 시작하세요; 나중에 바꾸면 프로젝트 전체에 파급 효과가 있습니다.
- VSCode + PlatformIO 로 시작하세요; 마이그레이션이 곧 큰 이득을 줍니다.
전체 스타터 키트는 GitHub 에서 제공됩니다. 클론하고, M5StickC‑Plus2에 업로드한 뒤 바로 빌드를 시작하세요!