net0040
Published © GPL3+

A Fun Hexagonal Prism Alarm Clock Based on M5Stack AtomS3

By rotating the hexagonal prism to different angles on the desktop, different timer durations can be set. The alarm clock has a sleep mode

BeginnerFull instructions provided5 hours13
A Fun Hexagonal Prism Alarm Clock Based on M5Stack AtomS3

Things used in this project

Hardware components

StickS3
M5Stack StickS3
3D printed shell: 1 top cover, 1 bottom cover, and 1 hexagonal prism structure
×1

Software apps and online services

VS Code
Microsoft VS Code
pioarduino

Hand tools and fabrication machines

Multitool, Screwdriver
Multitool, Screwdriver
2 Countersunk head screws M2*3

Story

Read more

Custom parts and enclosures

STL file for 3D printed shell

Top cover and bottom cover Printing with Bambu Lab A1Mini

STL file for 3D printed shell

Hexagonal prism structure

Code

Main program code : main.cpp

C/C++
M5Stack StickS3 Hexagonal Prism 5-Pose Timer Clock
/**
    • HexTimer - M5Stack StickS3 Hexagonal Prism 5-Pose Timer Clock
    • Function: Uses the BMI270 6-axis IMU sensor's accelerometer data
    • to identify 5 poses of the hexagonal prism when rolled on a desktop,
    • each pose corresponds to a different timer duration.
    • Press the panel button to start the countdown; the countdown ends or
    • reset terminates the countdown.
    • Timer configuration:
    • Pose 1: 10 seconds
    • Pose 2: 20 seconds
    • Pose 3: 30 seconds
    • Pose 4: 40 seconds
    • Pose 5: 50 seconds
    • Operation logic:
        1. After identifying a pose, the LCD displays the corresponding timer duration.
        2. Press the panel button to start the countdown.
        3. Countdown ends → plays "beep beep beep" sound.
        4. Reset pose (6/7) → terminates countdown → plays "booo" sound.
        5. After finishing, automatically returns to standby state.
        6. Reset pose terminates the countdown.
    • Screen auto-rotation:
    • Pose 1/2/3/6/7: normal orientation
    • Pose 4/5: 180° flipped, text automatically stays "head up"
    • Hardware configuration:
        1. ESP32-S3-PICO-1-N8R8 main controller
        2. BMI270 6-axis IMU sensor (accelerometer + gyroscope)
        3. 1.14" LCD display (135x240 resolution)
        4. Built-in speaker (driven via M5PM1)
*/
#include <Arduino.h>
#include <M5Unified.h>
#include <M5PM1.h>
#include "SparkFun_BMI270_Arduino_Library.h"

// Low power management
M5PM1 pm1;
BMI270 imu;

// Refresh rate configuration
#define REFRESH_RATE_HZ 30
#define REFRESH_INTERVAL_MS (1000 / REFRESH_RATE_HZ)
// Screen layout configuration
const int SCREEN_WIDTH = 240;
const int SCREEN_HEIGHT = 135;
// Color definitions
#define COLOR_BG TFT_BLACK
#define COLOR_TEXT TFT_WHITE
#define COLOR_TIMER TFT_YELLOW
#define COLOR_FINISH TFT_GREEN
#define COLOR_STOP TFT_ORANGE
#define COLOR_BAR_BG 0x2104 // Dark gray
// Timer duration configuration (milliseconds)
const unsigned long TIMER_DURATIONS[] = {
0, // Placeholder (pose 0)
10000, // Pose 1: 10 seconds
20000, // Pose 2: 20 seconds
30000, // Pose 3: 30 seconds
40000, // Pose 4: 40 seconds
50000, // Pose 5: 50 seconds
};
// State definitions
enum TimerState {
STATE_IDLE, // Standby: identify pose, display timer duration
STATE_COUNTDOWN, // Countdown in progress
STATE_FINISHED, // Countdown finished (natural end)
STATE_STOPPED // Countdown terminated (user reset)
};
// Global variables
unsigned long lastRefreshTime = 0;
float accX = 0;
float accY = 0;
float accZ = 0;
// Current pose (1-7)
int currentFace = 0;
int lastRotationFace = 0; // Last pose that set the rotation
// Timer state
TimerState timerState = STATE_IDLE;
unsigned long countdownEndTime = 0; // Countdown end time point
unsigned long countdownDuration = 0; // Total countdown duration
int countdownFace = 0; // Pose corresponding to the countdown
// Sound play flag
bool soundPlayed = false;

// Low power management
static unsigned long resetPoseStartTime = 0;
static bool isInSleepReady = false;
static const unsigned long RESET_POSE_SLEEP_DELAY_MS = 10000; // Enter sleep after 10 seconds

// Detect current pose
// Returns 1-7 for 7 poses, 0 for unknown
int detectFace() {
// Detect reset pose 1: x≈1, y≈0, z≈0 (bottom side down, USB port up)
if (accX > 0.5f && accX < 1.1f && abs(accY) < 0.5f && abs(accZ) < 0.5f) {
return 6; // Reset 1: USB port up
}
// Detect reset pose 2: x≈-1, y≈0, z≈0 (other bottom side down, PIN port up)
if (accX > -1.1f && accX < -0.5f && abs(accY) < 0.5f && abs(accZ) < 0.5f) {
return 7; // Reset 2: PIN port up
}
// Detect pose 3: x≈0, y≈0, z≈1 (z close to 1, x and y close to 0)
if (accZ > 0.85f && accZ < 1.05f && abs(accX) < 0.3f && abs(accY) < 0.3f) {
return 3; // Pose 3: 180 degrees parallel
}
// Detect pose 1: x≈0, y≈0.9, z≈-0.5
if (accY > 0.6f && accY < 1.0f && accZ > -0.9f && accZ < -0.1f && abs(accX) < 0.3f) {
return 1; // Pose 1: 60 degrees
}
// Detect pose 2: x≈0, y≈0.9, z≈0.5
if (accY > 0.6f && accY < 1.0f && accZ > 0.1f && accZ < 0.9f && abs(accX) < 0.3f) {
return 2; // Pose 2: 120 degrees
}
// Detect pose 4: x≈0, y≈-0.9, z≈0.5
if (accY > -1.0f && accY < -0.6f && accZ > 0.1f && accZ < 0.9f && abs(accX) < 0.3f) {
return 4; // Pose 4: 240 degrees
}
// Detect pose 5: x≈0, y≈-0.9, z≈-0.5
if (accY > -1.0f && accY < -0.6f && accZ > -0.9f && accZ < -0.1f && abs(accX) < 0.3f) {
return 5; // Pose 5: 300 degrees
}
return 0; // Cannot determine pose
}
// Set screen rotation based on pose
// Pose 1/2/3/6/7: normal orientation (rotation 1)
// Pose 4/5: 180° flipped (rotation 3)
void setScreenRotation(int face) {
int rotation;
if (face == 4 || face == 5) {
rotation = 3; // Flip 180°
} else {
rotation = 1; // Normal orientation
}
if (rotation != lastRotationFace) {
M5.Display.setRotation(rotation);
lastRotationFace = rotation;
M5.Display.fillScreen(COLOR_BG);
}
}
// Play sound
// type: 0 = countdown end sound, 1 = user reset sound
void playSound(int type) {
if (type == 0) {
// Countdown end: play 3 short high-pitched beeps "beep beep beep"
for (int i = 0; i < 3; i++) {
M5.Speaker.tone(2000, 150); // 2000Hz, 150ms
delay(200);
}
} else {
// User reset: play 1 low long tone "booo"
M5.Speaker.tone(500, 500); // 500Hz, 500ms
delay(500);
}
M5.Speaker.stop();
}
// Start countdown
void startCountdown(int face) {
timerState = STATE_COUNTDOWN;
countdownFace = face;
countdownDuration = TIMER_DURATIONS[face];
countdownEndTime = millis() + countdownDuration;
soundPlayed = false;
Serial.printf("[TIMER] ====\n",
face, countdownDuration / 1000);
}
// Stop countdown (user reset)
void stopCountdown() {
timerState = STATE_STOPPED;
soundPlayed = false;
countdownEndTime = 0;
Serial.println("[TIMER] =====");
}
// Countdown natural end
void finishCountdown() {
timerState = STATE_FINISHED;
soundPlayed = false;
countdownEndTime = 0;
Serial.println("[TIMER] ====");
}
// Draw idle screen
void drawIdleScreen() {
M5.Display.fillScreen(COLOR_BG);
if (currentFace >= 1 && currentFace <= 5) {
// Display timer duration in extra large font
M5.Display.setTextColor(COLOR_TIMER);
M5.Display.setTextSize(6);
char timerStr[10];
snprintf(timerStr, sizeof(timerStr), "%lu s", TIMER_DURATIONS[currentFace] / 1000);
M5.Display.drawString(timerStr, 10, 15);
// Prompt to press button to start
M5.Display.setTextColor(TFT_GRAY);
M5.Display.setTextSize(1);
M5.Display.drawString("Press button to start", 10, 105);
} else if (currentFace == 6 || currentFace == 7) {
M5.Display.setTextColor(TFT_GRAY);
M5.Display.setTextSize(2);
M5.Display.drawString("Reset Pose", 10, 40);
M5.Display.setTextSize(1);
M5.Display.drawString("Place to side to set timer", 10, 80);
} else {
M5.Display.setTextColor(TFT_GRAY);
M5.Display.setTextSize(2);
M5.Display.drawString("Detecting...", 10, 40);
}
}
// Draw countdown screen
void drawCountdownScreen() {
M5.Display.fillScreen(COLOR_BG);
// Calculate remaining time
unsigned long remaining = 0;
if (millis() < countdownEndTime) {
remaining = (countdownEndTime - millis() + 500) / 1000; // Round to seconds
}
// Display remaining time in extra large font
M5.Display.setTextColor(COLOR_TIMER);
M5.Display.setTextSize(8);
char timeStr[10];
snprintf(timeStr, sizeof(timeStr), "%lu", remaining);
M5.Display.drawString(timeStr, 10, 10);
// Seconds unit
M5.Display.setTextSize(3);
M5.Display.drawString("s", 10 + strlen(timeStr) * 48, 25);
// Display progress bar
int barWidth = 220;
int barHeight = 12;
int barX = 10;
int barY = 115;
// Background
M5.Display.fillRoundRect(barX, barY, barWidth, barHeight, 6, COLOR_BAR_BG);
// Progress
if (countdownDuration > 0) {
unsigned long elapsed = countdownDuration - remaining * 1000;
int progress = (int)((float)elapsed / countdownDuration * barWidth);
if (progress > barWidth) progress = barWidth;
if (progress > 0) {
M5.Display.fillRoundRect(barX, barY, progress, barHeight, 6, COLOR_TIMER);
}
}
}
// Draw finished screen
void drawFinishedScreen() {
M5.Display.fillScreen(COLOR_BG);
M5.Display.setTextColor(COLOR_FINISH);
M5.Display.setTextSize(3);
M5.Display.drawString("Time's Up!", 10, 40);
M5.Display.setTextColor(TFT_GRAY);
M5.Display.setTextSize(1);
M5.Display.drawString("Change pose for next timer", 10, 100);
}
// Draw stopped screen
void drawStoppedScreen() {
M5.Display.fillScreen(COLOR_BG);
M5.Display.setTextColor(COLOR_STOP);
M5.Display.setTextSize(3);
M5.Display.drawString("Timer", 10, 30);
M5.Display.drawString("Stopped", 10, 60);
M5.Display.setTextColor(TFT_GRAY);
M5.Display.setTextSize(1);
M5.Display.drawString("Change pose for next timer", 10, 110);
}
// Initialization function
void setup() {
// Initialize serial (for debug output)
Serial.begin(115200);
Serial.setDebugOutput(true);
Serial.println();
Serial.println("=================================");
Serial.println("HexTimer - 5 Pose Timer Clock");
Serial.println("=================================");

// Initialize M5Unified
M5.begin();

// Turn off external 5V output to reduce capacitor noise
M5.Power.setExtOutput(false);

// Configure speaker
M5.Speaker.begin();
M5.Speaker.setVolume(200); // Set volume (0-255)

// Configure screen
M5.Display.setRotation(1); // Landscape display
M5.Display.fillScreen(COLOR_BG);

Serial.println("[INFO] System initialization complete");
Serial.println("[INFO] Timer configuration:");
Serial.println("[INFO] Pose 1: 10 seconds");
Serial.println("[INFO] Pose 2: 20 seconds");
Serial.println("[INFO] Pose 3: 30 seconds");
Serial.println("[INFO] Pose 4: 40 seconds");
Serial.println("[INFO] Pose 5: 50 seconds");
Serial.println("[INFO] Reset 1/2: Terminate countdown");

// ==== Low power management initialization ====
// Get I2C pins (only used for PM1 initialization parameters)
auto pin_num_sda = M5.getPin(m5::pin_name_t::in_i2c_sda);
auto pin_num_scl = M5.getPin(m5::pin_name_t::in_i2c_scl);
Serial.printf("[PM1] getPin: SDA:%u SCL:%u\n", pin_num_sda, pin_num_scl);

// Note: Do NOT reinitialize Wire! M5.begin() has already initialized the I2C bus and BMI270 IMU.
// Reinitializing Wire would break the IMU driver inside M5Unified, causing M5.Imu.getAccel() to fail.
// PM1 directly uses the Wire object already initialized by M5.begin().

// Initialize PM1
m5pm1_err_t err = pm1.begin(&Wire, M5PM1_DEFAULT_ADDR, pin_num_sda, pin_num_scl, M5PM1_I2C_FREQ_100K);
if (err == M5PM1_OK) {
    Serial.println("[PM1] PM1 initialization successful");
    // Configure GPIO4 as wake-up pin (connected to IMU interrupt)
    pm1.gpioSetWakeEnable(M5PM1_GPIO_NUM_4, true);
    pm1.gpioSetWakeEdge(M5PM1_GPIO_NUM_4, M5PM1_GPIO_WAKE_FALLING);
} else {
    Serial.printf("[PM1] PM1 initialization failed, error code: %d\n", err);
}

// BMI270 interrupt configuration is not initialized in setup to avoid interfering with the IMU driver inside M5Unified.
// Interrupt configuration will be temporarily initialized using the SparkFun BMI270 library in loop() before entering sleep.
}
// Main loop
void loop() {
M5.update(); // Update button state

// ==== Low power management: check whether to enter sleep ====
// When in reset pose (6 or 7) and held for 10 seconds, enter sleep mode
if (currentFace == 6 || currentFace == 7) {
    if (!isInSleepReady) {
        // Record the time when reset pose was entered
        resetPoseStartTime = millis();
        isInSleepReady = true;
        Serial.println("[PM1] Reset pose detected, starting 10s sleep timer...");
    }
    
    // Check if 10 seconds have elapsed
    if (millis() - resetPoseStartTime >= RESET_POSE_SLEEP_DELAY_MS) {
        // Display prompt before entering sleep
        M5.Display.fillScreen(COLOR_BG);
        M5.Display.setTextColor(TFT_GRAY);
        M5.Display.setTextSize(2);
        M5.Display.drawString("Sleeping...", 10, 40);
        M5.Display.drawString("Shake to wake", 10, 80);
        delay(1000);
        
        // ==== Configure IMU interrupt for wake-up ====
        // Note: The following operations will reinitialize the I2C bus, which will break M5Unified's IMU driver.
        // But that's fine because we are about to enter sleep, and the ESP32-S3 will power-cycle on wake.
        auto pin_num_sda = M5.getPin(m5::pin_name_t::in_i2c_sda);
        auto pin_num_scl = M5.getPin(m5::pin_name_t::in_i2c_scl);
        Wire.end();
        Wire.begin(pin_num_sda, pin_num_scl, 100000U);
        
        // Initialize SparkFun BMI270 library
        BMI270 sleepImu;
        while(sleepImu.beginI2C(BMI2_I2C_PRIM_ADDR) != BMI2_OK) {
            Serial.println("[PM1] BMI270 not connected for wakeup config!");
            delay(500);
        }
        
        // Enable ANY_MOTION interrupt
        int8_t ret = sleepImu.enableFeature(BMI2_ANY_MOTION);
        
        // Configure interrupt pin
        bmi2_int_pin_config intPinConfig;
        intPinConfig.pin_type = BMI2_INT1;
        intPinConfig.int_latch = BMI2_INT_NON_LATCH;
        intPinConfig.pin_cfg[0].lvl = BMI2_INT_ACTIVE_LOW;
        intPinConfig.pin_cfg[0].od = BMI2_INT_PUSH_PULL;
        intPinConfig.pin_cfg[0].output_en = BMI2_INT_OUTPUT_ENABLE;
        intPinConfig.pin_cfg[0].input_en = BMI2_INT_INPUT_DISABLE;
        ret |= sleepImu.setInterruptPinConfig(intPinConfig);
        ret |= sleepImu.mapInterruptToPin(BMI2_ANY_MOTION_INT, BMI2_INT1);
        
        if (!ret) {
            Serial.println("[PM1] BMI270 AnyMotion interrupt enabled for wakeup");
        } else {
            Serial.println("[PM1] Failed to enable AnyMotion interrupt");
        }
        
        // Prepare to enter sleep: keep LDO powered
        pm1.setLdoEnable(true);
        pm1.ldoSetPowerHold(true);
        pm1.setLedEnLevel(true);
        
        // Enter sleep mode
        Serial.println("[PM1] Entering sleep mode...");
        pm1.shutdown();
        
        // After wake from sleep, reset state
        Serial.println("[PM1] Woken up by IMU motion!");
        isInSleepReady = false;
        resetPoseStartTime = 0;
        
        // Reinitialize display
        M5.Display.fillScreen(COLOR_BG);
        M5.Display.setRotation(1);
    }
} else {
    // Not in reset pose, reset sleep timer
    isInSleepReady = false;
    resetPoseStartTime = 0;
}

// Check if refresh is needed
unsigned long currentTime = millis();
if (currentTime - lastRefreshTime < REFRESH_INTERVAL_MS) {
// Even if screen is not refreshed, check button state (to avoid missing button presses)
if (timerState == STATE_IDLE) {
if (M5.BtnA.wasPressed() || M5.BtnB.wasPressed()) {
if (currentFace >= 1 && currentFace <= 5) {
startCountdown(currentFace);
}
}
}
return;
}
lastRefreshTime = currentTime;
// Read IMU data
M5.Imu.getAccel(&accX, &accY, &accZ);
// Detect current pose
int detectedFace = detectFace();
// Add hysteresis to avoid frequent pose changes
static int stableCount = 0;
static int lastDetectedFace = 0;
if (detectedFace == lastDetectedFace) {
stableCount++;
} else {
stableCount = 0;
lastDetectedFace = detectedFace;
}
// Update only after 10 consecutive detections of the same pose
if (stableCount > 10 && detectedFace != currentFace) {
currentFace = detectedFace;
Serial.printf("[INFO] ====\n", currentFace);
}
// ====
switch (timerState) {
case STATE_IDLE: {
// Standby state: when pose 1-5 is detected, display corresponding timer duration
// Press panel button to start countdown (StickS3 only has BtnA and BtnB)
if (M5.BtnA.wasPressed() || M5.BtnB.wasPressed()) {
if (currentFace >= 1 && currentFace <= 5) {
startCountdown(currentFace);
}
}
break;
}
case STATE_COUNTDOWN: {
// Countdown in progress: check for timeout or user reset
if (currentTime >= countdownEndTime) {
// Countdown natural end
finishCountdown();
} else if (currentFace == 6 || currentFace == 7) {
// User reset terminates
stopCountdown();
}
break;
}
case STATE_FINISHED: {
// Countdown finished: play sound
if (!soundPlayed) {
playSound(0); // Countdown end sound
soundPlayed = true;
}
// Return to standby after pose change
if (currentFace >= 1 && currentFace <= 5) {
timerState = STATE_IDLE;
Serial.println("[TIMER] ====");
}
break;
}
case STATE_STOPPED: {
// Countdown terminated: play sound
if (!soundPlayed) {
playSound(1); // User reset sound
soundPlayed = true;
}
// Return to standby after pose change
if (currentFace >= 1 && currentFace <= 5) {
timerState = STATE_IDLE;
Serial.println("[TIMER] ====");
}
break;
}
}
// Set screen rotation based on current pose
setScreenRotation(currentFace);
// Draw screen content
switch (timerState) {
case STATE_IDLE:
drawIdleScreen();
break;
case STATE_COUNTDOWN:
drawCountdownScreen();
break;
case STATE_FINISHED:
drawFinishedScreen();
break;
case STATE_STOPPED:
drawStoppedScreen();
break;
}
// Serial debug output
Serial.printf("[DEBUG] Acc: X=%.3f Y=%.3f Z=%.3f | Pose=%d | State=%d\n",
accX, accY, accZ, currentFace, timerState);
}

Credits

net0040
1 project • 0 followers

Comments