Arvind SA
Published © MIT

M5 StickC Plus 2 as a Pomodoro Timer

Turn your M5StickC Plus 2 into a sleek Pomodoro timer—focus sprints, break alerts, and buzzer all in your pocket

BeginnerFull instructions provided1 hour50
M5 StickC Plus 2 as a Pomodoro Timer

Things used in this project

Hardware components

M5StickC PLUS ESP32-PICO Mini IoT Development Kit
M5Stack M5StickC PLUS ESP32-PICO Mini IoT Development Kit
×1

Software apps and online services

Arduino IDE
Arduino IDE

Story

Read more

Code

Code

C/C++
/*
 * Pomodoro Stick - Pomodoro Timer for M5StickC Plus2
 *
 * Button Controls:
 * - Menu Mode: A=Next program, A Long=Select, B=Prev program
 * - Timer Running: A=Pause, A Long=Skip step, B Long=Exit dialog
 * - Timer Paused: A=Resume, A Long=Skip step, B Long=Exit dialog
 * - Exit Dialog: A=Confirm exit, B=Cancel
 */

#include <M5Unified.h>

// ============================================================================
// Constants
// ============================================================================

#define LONG_PRESS_MS 800
#define DEBOUNCE_MS 50

// Colors
#define COLOR_BG        TFT_BLACK
#define COLOR_TEXT      TFT_WHITE
#define COLOR_WORK      0xFD20    // Orange
#define COLOR_BREAK     0x07E0    // Green
#define COLOR_ACCENT    0x07FF    // Cyan
#define COLOR_DIM       0x7BEF    // Gray

// Screen dimensions (M5StickC Plus2 in landscape: 240x135)
#define SCREEN_W 240
#define SCREEN_H 135

// Top margin to account for LCD offset from case window
#define TOP_MARGIN 2

// ============================================================================
// Data Structures
// ============================================================================

struct PomodoroStep {
  uint16_t duration;      // Duration in seconds
  uint16_t beepFrequency; // Beep frequency (0 = no beep)
  const char* label;      // Step label
};

struct PomodoroProgram {
  const char* name;
  PomodoroStep* steps;
  uint8_t stepCount;
  int8_t loopCount;       // -1 = infinite, 1+ = n times
};

enum AppState {
  STATE_MENU,
  STATE_TIMER_RUNNING,
  STATE_TIMER_PAUSED,
  STATE_CONFIRM_EXIT
};

struct ButtonEvent {
  bool pressed;
  bool longPressed;
  bool released;
};

// ============================================================================
// Program Definitions
// ============================================================================

// Classic Pomodoro 25/5
PomodoroStep pomodoro25_5_steps[] = {
  {25 * 60, 1000, "Work"},
  {5 * 60, 800, "Break"}
};

// Short Focus 15/3
PomodoroStep shortFocus_steps[] = {
  {15 * 60, 1000, "Work"},
  {3 * 60, 800, "Break"}
};

// Long Session 50/10
PomodoroStep longSession_steps[] = {
  {50 * 60, 1000, "Work"},
  {10 * 60, 800, "Break"}
};

// Quick Test 10s/5s
PomodoroStep quickTest_steps[] = {
  {10, 1000, "Work"},
  {5, 800, "Break"}
};

// Programs array
PomodoroProgram programs[] = {
  {"Pomodoro 25/5", pomodoro25_5_steps, 2, 4},
  {"Short Focus", shortFocus_steps, 2, 6},
  {"Long Session", longSession_steps, 2, 2},
  {"Quick Test", quickTest_steps, 2, 2}
};

const uint8_t PROGRAM_COUNT = sizeof(programs) / sizeof(programs[0]);

// ============================================================================
// Global State
// ============================================================================

AppState currentState = STATE_MENU;
uint8_t currentProgramIndex = 0;

// Timer state
uint32_t remainingSeconds = 0;
uint8_t currentStepIndex = 0;
uint8_t currentLoop = 0;
uint32_t lastTickMillis = 0;

// Button state
bool btnA_wasPressed = false;
bool btnB_wasPressed = false;
uint32_t btnA_pressStart = 0;
uint32_t btnB_pressStart = 0;
bool btnA_longHandled = false;
bool btnB_longHandled = false;

// UI state
bool needsRedraw = true;

// Double buffer canvas (sprite) to prevent flickering
M5Canvas canvas(&M5.Lcd);

// ============================================================================
// Helper Functions
// ============================================================================

void formatTime(uint32_t seconds, char* buffer) {
  uint16_t mins = seconds / 60;
  uint16_t secs = seconds % 60;
  sprintf(buffer, "%02d:%02d", mins, secs);
}

uint32_t calculateTotalTime(PomodoroProgram* prog) {
  uint32_t total = 0;
  for (uint8_t i = 0; i < prog->stepCount; i++) {
    total += prog->steps[i].duration;
  }
  return total * prog->loopCount;
}

uint16_t getStepColor(const char* label) {
  if (strcmp(label, "Work") == 0) return COLOR_WORK;
  if (strcmp(label, "Break") == 0) return COLOR_BREAK;
  return COLOR_TEXT;
}

void playBeep(uint16_t frequency) {
  if (frequency > 0) {
    M5.Speaker.tone(frequency, 200);
    delay(250);
    M5.Speaker.tone(frequency, 200);
  }
}

// ============================================================================
// Button Handling
// ============================================================================

ButtonEvent checkButton(m5::Button_Class& btn, bool& wasPressed,
                        uint32_t& pressStart, bool& longHandled) {
  ButtonEvent event = {false, false, false};

  if (btn.isPressed()) {
    if (!wasPressed) {
      // Button just pressed
      wasPressed = true;
      pressStart = millis();
      longHandled = false;
    } else if (!longHandled && (millis() - pressStart >= LONG_PRESS_MS)) {
      // Long press detected
      event.longPressed = true;
      longHandled = true;
    }
  } else {
    if (wasPressed) {
      // Button released
      if (!longHandled) {
        event.pressed = true;  // Short press
      }
      event.released = true;
      wasPressed = false;
    }
  }

  return event;
}

// ============================================================================
// UI Drawing Functions
// ============================================================================

void drawMenuScreen() {
  canvas.fillScreen(COLOR_BG);

  PomodoroProgram* prog = &programs[currentProgramIndex];
  char timeStr[10];

  // Program name (top, centered, large)
  canvas.setTextColor(COLOR_ACCENT);
  canvas.setTextSize(3);
  canvas.setTextDatum(TC_DATUM);
  canvas.drawString(prog->name, SCREEN_W / 2, TOP_MARGIN + 3);

  // Separator line
  canvas.drawLine(10, TOP_MARGIN + 33, SCREEN_W - 10, TOP_MARGIN + 33, COLOR_DIM);

  // Steps list (left side)
  canvas.setTextSize(2);
  canvas.setTextDatum(TL_DATUM);
  int yPos = TOP_MARGIN + 43;
  uint8_t displaySteps = min((uint8_t)2, prog->stepCount);

  for (uint8_t i = 0; i < displaySteps; i++) {
    PomodoroStep* step = &prog->steps[i];

    // Step label
    canvas.setTextColor(getStepColor(step->label));
    canvas.drawString(step->label, 10, yPos);

    // Step duration
    formatTime(step->duration, timeStr);
    canvas.setTextColor(COLOR_TEXT);
    canvas.drawString(timeStr, 75, yPos);

    yPos += 25;
  }

  // Right side: Total time and loop info
  canvas.setTextDatum(TL_DATUM);
  canvas.setTextColor(COLOR_DIM);
  canvas.drawString("Total", 155, TOP_MARGIN + 43);
  uint32_t totalTime = calculateTotalTime(prog);
  formatTime(totalTime, timeStr);
  canvas.setTextColor(COLOR_TEXT);
  canvas.drawString(timeStr, 155, TOP_MARGIN + 68);

  // Loop info
  canvas.setTextColor(COLOR_DIM);
  canvas.setTextDatum(TL_DATUM);
  char loopStr[20];
  if (prog->loopCount < 0) {
    sprintf(loopStr, "Loop: inf");
  } else {
    sprintf(loopStr, "x%d", prog->loopCount);
  }
  canvas.drawString(loopStr, 10, TOP_MARGIN + 98);

  // Program indicator bar (thin rectangle)
  uint16_t barWidth = 80;
  uint16_t segmentWidth = barWidth / PROGRAM_COUNT;
  uint16_t barX = (SCREEN_W - barWidth) / 2;
  uint16_t barY = SCREEN_H - 6;

  canvas.drawRect(barX, barY, barWidth, 4, COLOR_DIM);
  canvas.fillRect(barX + currentProgramIndex * segmentWidth, barY, segmentWidth, 4, COLOR_ACCENT);
}

void drawTimerScreen() {
  canvas.fillScreen(COLOR_BG);

  PomodoroProgram* prog = &programs[currentProgramIndex];
  PomodoroStep* step = &prog->steps[currentStepIndex];
  char timeStr[10];

  // Step label (top left, large)
  canvas.setTextColor(getStepColor(step->label));
  canvas.setTextSize(3);
  canvas.setTextDatum(TL_DATUM);
  canvas.drawString(step->label, 10, TOP_MARGIN + 3);

  // Status indicator (top right)
  canvas.setTextSize(2);
  canvas.setTextDatum(TR_DATUM);
  if (currentState == STATE_TIMER_PAUSED) {
    canvas.setTextColor(COLOR_WORK);
    canvas.drawString("PAUSED", SCREEN_W - 10, TOP_MARGIN + 6);
  } else {
    canvas.setTextColor(COLOR_BREAK);
    canvas.drawString("RUN", SCREEN_W - 10, TOP_MARGIN + 6);
  }

  // Large countdown timer (center)
  formatTime(remainingSeconds, timeStr);
  canvas.setTextSize(5);
  canvas.setTextColor(COLOR_TEXT);
  canvas.setTextDatum(TC_DATUM);
  canvas.drawString(timeStr, SCREEN_W / 2, TOP_MARGIN + 36);

  // Progress bar (below timer)
  uint16_t barWidth = SCREEN_W - 20;
  uint16_t barHeight = 8;
  uint16_t barX = 10;
  uint16_t barY = TOP_MARGIN + 88;
  float progress = 1.0 - ((float)remainingSeconds / step->duration);
  uint16_t fillWidth = (uint16_t)(barWidth * progress);

  canvas.drawRect(barX, barY, barWidth, barHeight, COLOR_DIM);
  if (fillWidth > 0) {
    canvas.fillRect(barX + 1, barY + 1, fillWidth - 2, barHeight - 2, getStepColor(step->label));
  }

  // Bottom row: Next step and Loop counter
  canvas.setTextSize(2);
  canvas.setTextDatum(TL_DATUM);

  // Next step preview (bottom left)
  uint8_t nextStepIndex = (currentStepIndex + 1) % prog->stepCount;
  uint8_t nextLoop = currentLoop;
  if (nextStepIndex == 0) nextLoop++;

  if (nextLoop < prog->loopCount || prog->loopCount < 0) {
    PomodoroStep* nextStep = &prog->steps[nextStepIndex];
    canvas.setTextColor(getStepColor(nextStep->label));
    canvas.drawString(nextStep->label, 10, TOP_MARGIN + 106);
    formatTime(nextStep->duration, timeStr);
    canvas.setTextColor(COLOR_TEXT);
    canvas.drawString(timeStr, 75, TOP_MARGIN + 106);
  } else {
    canvas.setTextColor(COLOR_ACCENT);
    canvas.drawString("Last!", 10, TOP_MARGIN + 106);
  }

  // Loop counter (bottom right)
  canvas.setTextColor(COLOR_DIM);
  canvas.setTextDatum(TR_DATUM);
  char loopStr[12];
  if (prog->loopCount < 0) {
    sprintf(loopStr, "#%d", currentLoop + 1);
  } else {
    sprintf(loopStr, "%d/%d", currentLoop + 1, prog->loopCount);
  }
  canvas.drawString(loopStr, SCREEN_W - 10, TOP_MARGIN + 106);
}

void drawConfirmDialog() {
  // Draw dialog box on top of canvas (after timer screen is drawn)
  canvas.fillRect(30, 20, 180, 95, COLOR_BG);
  canvas.drawRect(30, 20, 180, 95, COLOR_ACCENT);

  // Dialog content
  canvas.setTextColor(COLOR_TEXT);
  canvas.setTextSize(3);
  canvas.setTextDatum(TC_DATUM);
  canvas.drawString("Exit?", SCREEN_W / 2, 35);

  canvas.setTextSize(2);
  canvas.setTextColor(COLOR_BREAK);
  canvas.drawString("A:Yes", 75, 80);
  canvas.setTextColor(COLOR_WORK);
  canvas.drawString("B:No", 165, 80);
}

// ============================================================================
// Timer Logic
// ============================================================================

void startTimer() {
  PomodoroProgram* prog = &programs[currentProgramIndex];
  currentStepIndex = 0;
  currentLoop = 0;
  remainingSeconds = prog->steps[0].duration;
  lastTickMillis = millis();
  currentState = STATE_TIMER_PAUSED;  // Start paused, user presses A to begin
  needsRedraw = true;
}

void advanceStep() {
  PomodoroProgram* prog = &programs[currentProgramIndex];

  // Play beep for completed step
  playBeep(prog->steps[currentStepIndex].beepFrequency);

  // Move to next step
  currentStepIndex++;

  // Check if we completed all steps in this loop
  if (currentStepIndex >= prog->stepCount) {
    currentStepIndex = 0;
    currentLoop++;

    // Check if all loops completed
    if (prog->loopCount >= 0 && currentLoop >= prog->loopCount) {
      // Timer complete - return to menu
      currentState = STATE_MENU;
      needsRedraw = true;
      return;
    }
  }

  // Start new step
  remainingSeconds = prog->steps[currentStepIndex].duration;
  lastTickMillis = millis();
  needsRedraw = true;
}

void updateTimer() {
  if (currentState != STATE_TIMER_RUNNING) return;

  uint32_t now = millis();
  if (now - lastTickMillis >= 1000) {
    lastTickMillis += 1000;

    if (remainingSeconds > 0) {
      remainingSeconds--;
      needsRedraw = true;

      if (remainingSeconds == 0) {
        advanceStep();
      }
    }
  }
}

// ============================================================================
// Main Setup & Loop
// ============================================================================

void setup() {
  auto cfg = M5.config();
  cfg.internal_spk = true;
  M5.begin(cfg);

  // Power management
  M5.Power.setLed(0);  // Turn off LED

  // Display setup (landscape, rotated 90 clockwise)
  M5.Lcd.setRotation(1);
  M5.Lcd.fillScreen(COLOR_BG);
  M5.Lcd.setTextWrap(false);

  // Create sprite for double buffering (eliminates flicker)
  canvas.createSprite(SCREEN_W, SCREEN_H);
  canvas.setTextWrap(false);

  // Speaker setup
  M5.Speaker.setVolume(128);

  // Initial draw
  needsRedraw = true;
}

void loop() {
  M5.update();

  // Check buttons
  ButtonEvent btnA = checkButton(M5.BtnA, btnA_wasPressed, btnA_pressStart, btnA_longHandled);
  ButtonEvent btnB = checkButton(M5.BtnB, btnB_wasPressed, btnB_pressStart, btnB_longHandled);

  // State machine
  switch (currentState) {
    case STATE_MENU:
      if (btnA.pressed) {
        // Next program
        currentProgramIndex = (currentProgramIndex + 1) % PROGRAM_COUNT;
        needsRedraw = true;
      }
      if (btnA.longPressed) {
        // Select and start
        startTimer();
      }
      if (btnB.pressed) {
        // Previous program
        if (currentProgramIndex == 0) {
          currentProgramIndex = PROGRAM_COUNT - 1;
        } else {
          currentProgramIndex--;
        }
        needsRedraw = true;
      }
      break;

    case STATE_TIMER_RUNNING:
      if (btnA.pressed) {
        // Pause
        currentState = STATE_TIMER_PAUSED;
        needsRedraw = true;
      }
      if (btnA.longPressed) {
        // Skip step
        advanceStep();
      }
      if (btnB.longPressed) {
        // Show exit confirmation
        currentState = STATE_CONFIRM_EXIT;
        needsRedraw = true;
      }
      // Update timer
      updateTimer();
      break;

    case STATE_TIMER_PAUSED:
      if (btnA.pressed) {
        // Resume
        lastTickMillis = millis();  // Reset tick reference
        currentState = STATE_TIMER_RUNNING;
        needsRedraw = true;
      }
      if (btnA.longPressed) {
        // Skip step
        lastTickMillis = millis();
        currentState = STATE_TIMER_RUNNING;
        advanceStep();
      }
      if (btnB.longPressed) {
        // Show exit confirmation
        currentState = STATE_CONFIRM_EXIT;
        needsRedraw = true;
      }
      break;

    case STATE_CONFIRM_EXIT:
      if (btnA.pressed) {
        // Confirm exit
        currentState = STATE_MENU;
        needsRedraw = true;
      }
      if (btnB.pressed) {
        // Cancel - return to paused state
        currentState = STATE_TIMER_PAUSED;
        needsRedraw = true;
      }
      break;
  }

  // Redraw if needed
  if (needsRedraw) {
    needsRedraw = false;

    switch (currentState) {
      case STATE_MENU:
        drawMenuScreen();
        break;
      case STATE_TIMER_RUNNING:
      case STATE_TIMER_PAUSED:
        drawTimerScreen();
        break;
      case STATE_CONFIRM_EXIT:
        drawTimerScreen();
        drawConfirmDialog();
        break;
    }

    // Push the canvas to display (single push eliminates flicker)
    canvas.pushSprite(0, 0);
  }

  delay(10);  // Small delay to prevent tight loop
}

Credits

Arvind SA
6 projects • 9 followers
Bio-mechatronics Engineer: Fusing mechanical and electronics expertise to create innovative products augmenting human capabilities.

Comments