Last active
May 31, 2026 05:45
-
-
Save tos-kamiya/22190ede3faf829c7bb4e0f2cb4b0525 to your computer and use it in GitHub Desktop.
A simple low-power M5StickC Plus countdown timer to help avoid drinking too much coffee.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Decafe Timer | |
| * For M5StickC Plus. | |
| * Compile and upload this sketch using the Arduino IDE. | |
| * | |
| * This app helps prevent drinking too much coffee. | |
| * After you drink coffee once, it starts a 5-hour countdown and reminds you not to drink the next coffee until the timer ends. | |
| * When the countdown reaches zero, a coffee illustration appears. | |
| * | |
| * Basic controls: | |
| * - Button A: Add 5 hours to the timer. | |
| * - Button B: Show the current state for 3 seconds. | |
| * - Power button: Reset the timer and show only the coffee cup for a moment. | |
| */ | |
| #include <M5StickCPlus.h> | |
| #include <WiFi.h> | |
| #include "esp_bt.h" | |
| #include "esp_sleep.h" | |
| // -------------------- Timer -------------------- | |
| uint64_t remainingMillis = 0; | |
| unsigned long previousMillis = 0; | |
| // Use 5 hours as one unit | |
| const uint64_t FIVE_HOURS_MILLIS = 5ULL * 3600ULL * 1000ULL; | |
| // Whether the timer has expired | |
| bool timerExpired = false; | |
| // -------------------- Display Control -------------------- | |
| // Keep the screen on until this time | |
| unsigned long displayUntil = 0; | |
| const unsigned long DISPLAY_DURATION_MS = 3000; | |
| const int NORMAL_BRIGHTNESS = 40; | |
| const int SHAKE_BRIGHTNESS = 100; | |
| bool actualDisplayOn = false; | |
| // Right after a power-button reset, show only the center coffee cup | |
| unsigned long resetCoffeeUntil = 0; | |
| const unsigned long RESET_COFFEE_DURATION_MS = 1000; | |
| // -------------------- IMU / Shake Detection -------------------- | |
| const float SHAKE_THRESHOLD_G = 1.6; | |
| unsigned long lastImuCheck = 0; | |
| // Check more often while the display is on, and less often while it is off | |
| const unsigned long IMU_INTERVAL_ON_MS = 100; | |
| const unsigned long IMU_INTERVAL_OFF_MS = 500; | |
| // -------------------- Screen Layout -------------------- | |
| const int SCREEN_W = 240; | |
| const int SCREEN_H = 135; | |
| // Remaining-time bar | |
| const int BAR_X = 10; | |
| const int BAR_Y = 82; | |
| const int BAR_W = 220; | |
| const int BAR_H = 16; | |
| // Battery dots | |
| const int BAT_X = 10; | |
| const int BAT_Y = 118; | |
| const int BAT_DOT_R = 4; | |
| const int BAT_DOT_SPACING = 14; | |
| const int BAT_DOT_COUNT = 10; | |
| uint16_t barColor = BLUE; | |
| // -------------------- Set a Random Bar Color -------------------- | |
| void updateBarColorFromMillis() { | |
| uint32_t x = millis(); | |
| // Mix the bits in a simple way | |
| x ^= x << 13; | |
| x ^= x >> 17; | |
| x ^= x << 5; | |
| uint8_t r = x & 0xFF; | |
| x ^= x << 13; | |
| x ^= x >> 17; | |
| x ^= x << 5; | |
| uint8_t rest = 255 - r; | |
| uint8_t g = (rest == 0) ? 0 : (x & 0xFF) % (rest + 1); | |
| uint8_t b = rest - g; | |
| barColor = M5.Lcd.color565(r, g, b); | |
| } | |
| // -------------------- Coffee Cup Drawing -------------------- | |
| bool isResetCoffeeActive() { | |
| return (long)(resetCoffeeUntil - millis()) > 0; | |
| } | |
| void drawCoffeeCup(int cx, int cy) { | |
| // Draw a simple coffee cup centered at cx, cy | |
| const uint16_t cupColor = WHITE; | |
| const uint16_t coffeeColor = 0x7BEF; // Light gray | |
| const int cupW = 42; | |
| const int cupH = 24; | |
| int x = cx - cupW / 2; | |
| int y = cy - cupH / 2 + 6; | |
| // Steam | |
| M5.Lcd.drawLine(cx - 14, y - 20, cx - 10, y - 28, DARKGREY); | |
| M5.Lcd.drawLine(cx - 10, y - 28, cx - 14, y - 36, DARKGREY); | |
| M5.Lcd.drawLine(cx, y - 20, cx + 4, y - 28, DARKGREY); | |
| M5.Lcd.drawLine(cx + 4, y - 28, cx, y - 36, DARKGREY); | |
| M5.Lcd.drawLine(cx + 14, y - 20, cx + 10, y - 28, DARKGREY); | |
| M5.Lcd.drawLine(cx + 10, y - 28, cx + 14, y - 36, DARKGREY); | |
| // Cup body | |
| M5.Lcd.fillRoundRect(x, y, cupW, cupH, 6, cupColor); | |
| // Coffee surface | |
| M5.Lcd.fillRoundRect(x + 5, y + 4, cupW - 10, 6, 3, coffeeColor); | |
| // Handle | |
| M5.Lcd.drawRoundRect(x + cupW - 2, y + 6, 16, 14, 7, cupColor); | |
| M5.Lcd.drawRoundRect(x + cupW, y + 8, 12, 10, 5, cupColor); | |
| // Saucer | |
| M5.Lcd.fillRoundRect(cx - 30, y + cupH + 6, 60, 5, 2, cupColor); | |
| } | |
| void drawResetCoffeeScreen() { | |
| M5.Lcd.fillScreen(BLACK); | |
| drawCoffeeCup(SCREEN_W / 2, SCREEN_H / 2); | |
| } | |
| // -------------------- Setup -------------------- | |
| void setup() { | |
| M5.begin(); | |
| M5.IMU.Init(); | |
| // Disable unused wireless features | |
| WiFi.mode(WIFI_OFF); | |
| btStop(); | |
| // Lower the CPU frequency. This is usually enough for drawing and the IMU. | |
| setCpuFrequencyMhz(80); | |
| M5.Lcd.setRotation(1); | |
| M5.Lcd.setTextSize(3); | |
| previousMillis = millis(); | |
| // Show the screen right after startup | |
| showForAWhile(NORMAL_BRIGHTNESS); | |
| } | |
| // -------------------- Display On/Off -------------------- | |
| bool shouldDisplayBeOn() { | |
| return ((long)(displayUntil - millis()) > 0) || isResetCoffeeActive(); | |
| } | |
| void showForAWhile(int brightness) { | |
| displayUntil = millis() + DISPLAY_DURATION_MS; | |
| M5.Axp.SetLDO2(true); | |
| M5.Axp.ScreenBreath(brightness); | |
| actualDisplayOn = true; | |
| drawTimer(); | |
| } | |
| void showResetCoffeeForAWhile() { | |
| resetCoffeeUntil = millis() + RESET_COFFEE_DURATION_MS; | |
| displayUntil = resetCoffeeUntil; | |
| M5.Axp.SetLDO2(true); | |
| M5.Axp.ScreenBreath(NORMAL_BRIGHTNESS); | |
| actualDisplayOn = true; | |
| drawResetCoffeeScreen(); | |
| } | |
| void updateDisplayPower() { | |
| if (shouldDisplayBeOn()) { | |
| if (!actualDisplayOn) { | |
| M5.Axp.SetLDO2(true); | |
| M5.Axp.ScreenBreath(NORMAL_BRIGHTNESS); | |
| actualDisplayOn = true; | |
| drawTimer(); | |
| } | |
| } else { | |
| if (actualDisplayOn) { | |
| M5.Axp.SetLDO2(false); | |
| actualDisplayOn = false; | |
| } | |
| } | |
| } | |
| // -------------------- Battery -------------------- | |
| int getBatteryPercent() { | |
| float vbat = M5.Axp.GetBatVoltage(); | |
| // A very rough estimate from voltage | |
| // 4.20V ≈ 100%, 3.30V ≈ 0% | |
| int percent = (int)((vbat - 3.30) / (4.20 - 3.30) * 100.0); | |
| if (percent < 0) { | |
| percent = 0; | |
| } | |
| if (percent > 100) { | |
| percent = 100; | |
| } | |
| return percent; | |
| } | |
| void drawBatteryDots() { | |
| int percent = getBatteryPercent(); | |
| int filledDots = (percent + 9) / 10; | |
| for (int i = 0; i < BAT_DOT_COUNT; i++) { | |
| int x = BAT_X + i * BAT_DOT_SPACING; | |
| int y = BAT_Y; | |
| if (i < filledDots) { | |
| M5.Lcd.fillCircle(x, y, BAT_DOT_R, WHITE); | |
| } else { | |
| M5.Lcd.drawCircle(x, y, BAT_DOT_R, DARKGREY); | |
| } | |
| } | |
| } | |
| // -------------------- Bar Graph -------------------- | |
| void drawOverFiveHourMark() { | |
| int y = BAR_Y - 14; | |
| int x1 = BAR_X + BAR_W - 24; | |
| int x2 = BAR_X + BAR_W - 12; | |
| M5.Lcd.fillTriangle(x1, y, x1, y + 10, x1 + 8, y + 5, WHITE); | |
| M5.Lcd.fillTriangle(x2, y, x2, y + 10, x2 + 8, y + 5, WHITE); | |
| } | |
| void drawBarGraph() { | |
| M5.Lcd.drawRect(BAR_X, BAR_Y, BAR_W, BAR_H, DARKGREY); | |
| if (remainingMillis == 0) { | |
| return; | |
| } | |
| // Fill the screen width in 5 hours. | |
| // Beyond 5 hours, the bar would extend off-screen. | |
| uint64_t barWidth64 = remainingMillis * BAR_W / FIVE_HOURS_MILLIS; | |
| // Clamp the value so it does not get too large for the TFT library | |
| int barWidth; | |
| if (barWidth64 > 1000) { | |
| barWidth = 1000; | |
| } else { | |
| barWidth = (int)barWidth64; | |
| } | |
| M5.Lcd.fillRect(BAR_X, BAR_Y, barWidth, BAR_H, barColor); | |
| if (remainingMillis > FIVE_HOURS_MILLIS) { | |
| drawOverFiveHourMark(); | |
| } | |
| } | |
| // -------------------- Screen Drawing -------------------- | |
| void drawTimer() { | |
| if (!shouldDisplayBeOn()) { | |
| return; | |
| } | |
| // Right after a power-button reset, | |
| // show only the coffee cup in the center of the black screen | |
| if (isResetCoffeeActive()) { | |
| drawResetCoffeeScreen(); | |
| return; | |
| } | |
| M5.Lcd.fillScreen(BLACK); | |
| if (remainingMillis == 0 && !timerExpired) { | |
| M5.Lcd.setTextColor(GREEN); | |
| M5.Lcd.setTextSize(2); | |
| M5.Lcd.setCursor(16, 32); | |
| M5.Lcd.print("PUSH A BUTTON"); | |
| } else { | |
| M5.Lcd.setTextColor(WHITE); | |
| M5.Lcd.setTextSize(3); | |
| M5.Lcd.setCursor(10, 28); | |
| // Round up for human-friendly display | |
| uint64_t totalSeconds = (remainingMillis + 999) / 1000; | |
| int hours = totalSeconds / 3600; | |
| int minutes = (totalSeconds % 3600) / 60; | |
| int seconds = totalSeconds % 60; | |
| M5.Lcd.printf("%02d:%02d:%02d", hours, minutes, seconds); | |
| } | |
| drawBarGraph(); | |
| drawBatteryDots(); | |
| // When the timer expires, keep the normal display | |
| // and add a coffee cup on the right side | |
| if (timerExpired && remainingMillis == 0) { | |
| drawCoffeeCup(194, 42); | |
| } | |
| } | |
| // -------------------- Shake Detection -------------------- | |
| void checkShake() { | |
| unsigned long now = millis(); | |
| unsigned long interval; | |
| if (shouldDisplayBeOn()) { | |
| interval = IMU_INTERVAL_ON_MS; | |
| } else { | |
| interval = IMU_INTERVAL_OFF_MS; | |
| } | |
| if (now - lastImuCheck < interval) { | |
| return; | |
| } | |
| lastImuCheck = now; | |
| float accX, accY, accZ; | |
| M5.IMU.getAccelData(&accX, &accY, &accZ); | |
| float g2 = accX * accX + accY * accY + accZ * accZ; | |
| float t2 = SHAKE_THRESHOLD_G * SHAKE_THRESHOLD_G; | |
| if (g2 > t2) { | |
| showForAWhile(SHAKE_BRIGHTNESS); | |
| } | |
| } | |
| // -------------------- Reset by Power Button -------------------- | |
| void checkPowerButtonReset() { | |
| static bool wasPowerPressed = false; | |
| uint8_t press = M5.Axp.GetBtnPress(); | |
| bool isPowerPressed = (press != 0); | |
| if (isPowerPressed && !wasPowerPressed) { | |
| remainingMillis = 0; | |
| timerExpired = false; | |
| showResetCoffeeForAWhile(); | |
| } | |
| wasPowerPressed = isPowerPressed; | |
| } | |
| // -------------------- Low-Power Wait -------------------- | |
| void lowPowerWait() { | |
| if (shouldDisplayBeOn()) { | |
| delay(50); | |
| } else { | |
| // Use light sleep only while the display is off | |
| // Wake every 300 ms to check the buttons, IMU, and timer | |
| esp_sleep_enable_timer_wakeup(300000ULL); | |
| esp_light_sleep_start(); | |
| } | |
| } | |
| // -------------------- Loop -------------------- | |
| void loop() { | |
| M5.update(); | |
| unsigned long currentMillis = millis(); | |
| unsigned long elapsedTime = currentMillis - previousMillis; | |
| previousMillis = currentMillis; | |
| // Button handling | |
| if (M5.BtnA.wasPressed()) { | |
| timerExpired = false; | |
| resetCoffeeUntil = 0; | |
| remainingMillis += FIVE_HOURS_MILLIS; | |
| updateBarColorFromMillis(); | |
| showForAWhile(NORMAL_BRIGHTNESS); | |
| } | |
| // BtnB shows the current state for only 3 seconds | |
| if (M5.BtnB.wasPressed()) { | |
| resetCoffeeUntil = 0; | |
| showForAWhile(NORMAL_BRIGHTNESS); | |
| } | |
| checkPowerButtonReset(); | |
| checkShake(); | |
| // Countdown handling | |
| if (remainingMillis > 0) { | |
| uint64_t oldDisplaySeconds = (remainingMillis + 999) / 1000; | |
| if ((uint64_t)elapsedTime >= remainingMillis) { | |
| remainingMillis = 0; | |
| timerExpired = true; | |
| // When the timer ends, turn the display on | |
| // and show the coffee cup in addition to the numbers, bar, and battery indicator | |
| showForAWhile(NORMAL_BRIGHTNESS); | |
| } else { | |
| remainingMillis -= elapsedTime; | |
| } | |
| uint64_t newDisplaySeconds = (remainingMillis + 999) / 1000; | |
| // Draw only while the display is on | |
| if (shouldDisplayBeOn()) { | |
| if (remainingMillis == 0 || newDisplaySeconds != oldDisplaySeconds) { | |
| drawTimer(); | |
| } | |
| } | |
| } | |
| updateDisplayPower(); | |
| // Sleep to save power | |
| lowPowerWait(); | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The timer has finished its 5-hour countdown and is now showing that it is time for a coffee break.
