Skip to content

Instantly share code, notes, and snippets.

@tos-kamiya
Last active May 31, 2026 05:45
Show Gist options
  • Select an option

  • Save tos-kamiya/22190ede3faf829c7bb4e0f2cb4b0525 to your computer and use it in GitHub Desktop.

Select an option

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.
/*
* 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();
}
@tos-kamiya

Copy link
Copy Markdown
Author

The timer has finished its 5-hour countdown and is now showing that it is time for a coffee break.
IMG20260531143416-c

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment