Yosuke Toyota
Published

M5 GraviTimer

Tilt-activated timer with a visual progress bar that auto-starts the preset countdown.

BeginnerFull instructions provided1 hour145
M5 GraviTimer

Things used in this project

Hardware components

M5Stack Core2 ESP32 IoT Development Kit
M5Stack Core2 ESP32 IoT Development Kit
×1

Software apps and online services

PlatformIO IDE
PlatformIO IDE

Story

Read more

Code

main.cpp

C/C++
#include <M5Unified.h>
#include <cmath>

// 向き
enum Orientation
{
  ORIENT_NONE,
  ORIENT_BOTTOM_DOWN, // ボタン側(底面)が下 -> 5分
  ORIENT_TOP_DOWN,    // 上端が下 -> 3分
  ORIENT_RIGHT_DOWN   // 右側面が下 -> 10秒
};

// タイマー用の変数
unsigned long startMillis = 0;
unsigned long elapsed = 0;
bool running = false;
unsigned long prevElapsed = 0;

// 向き検出関連
Orientation currentDetected = ORIENT_NONE;
Orientation lastStableDetected = ORIENT_NONE;
unsigned long orientDetectStart = 0;
const unsigned long ORIENT_HOLD_MS = 500;   // 0.5秒ホールドで新規開始
const unsigned long ORIENT_RESET_MS = 2000; // 2秒ホールドでリセット&再度の開始

// 各タイマーの時間(秒)
const unsigned long DURATION_BOTTOM_SEC = 300; // 5分
const unsigned long DURATION_TOP_SEC = 180;    // 3分
const unsigned long DURATION_RIGHT_SEC = 5;    // 5秒

// 連続トリガー防止
Orientation lastTriggered = ORIENT_NONE;

// 加速度判定閾値(g単位)
const float G_THRESHOLD = 0.8f;

// プログレスバー関連の変数
int lastBarWidth = -1; // 前回描画したバーの幅を記憶

// センサ値を g に正規化(m/s^2 の場合は 9.80665 で割る)
void normalizeToG(float &ax, float &ay, float &az)
{
  float maxAbs = fmaxf(fmaxf(fabsf(ax), fabsf(ay)), fabsf(az));
  if (maxAbs > 5.0f)
  {
    const float g0 = 9.80665f;
    ax /= g0;
    ay /= g0;
    az /= g0;
  }
}

// 現在の向きを判定(M5.Imu.getAccelData を利用)
Orientation detectOrientation()
{
  float ax = 0, ay = 0, az = 0;

  if (!M5.Imu.getAccelData(&ax, &ay, &az))
  {
    return ORIENT_NONE;
  }
  normalizeToG(ax, ay, az);

  float absx = fabsf(ax);
  float absy = fabsf(ay);
  float absz = fabsf(az);

  // 重力成分が大きい軸で判定
  float maxAxis = fmaxf(fmaxf(absx, absy), absz);

  // Y 軸となった場合(上下判定)
  if (absy == maxAxis && absy >= G_THRESHOLD)
  {
    if (ay >= G_THRESHOLD)
    {
      // ay が正(Y軸の正方向に重力) -> ボタン側(底面)が下
      return ORIENT_BOTTOM_DOWN;
    }
    else if (ay <= -G_THRESHOLD)
    {
      // ay が負(Y軸の負方向に重力) -> 上端が下
      return ORIENT_TOP_DOWN;
    }
  }
  // X 軸となった場合(左右判定)
  else if (absx == maxAxis && absx >= G_THRESHOLD)
  {
    if (ax <= -G_THRESHOLD)
    {
      // ax が負(X軸の負方向に重力) -> 右側面が下
      return ORIENT_RIGHT_DOWN;
    }
    // 左側面が下の場合は今回は何もしない
  }

  return ORIENT_NONE;
}

// プログレスバーを描画
void drawProgressBar(unsigned long remaining, unsigned long total)
{
  const int barY = 40;      // プログレスバーのY座標の位置
  const int barHeight = 30; // プログレスバーの高さ
  const int barX = 0;       // プログレスバーのX座標の開始位置
  int screenWidth = M5.Lcd.width();

  // 残り時間の割合を計算(0.0~1.0)
  float progress = (total > 0) ? (float)remaining / (float)total : 0.0;
  int barWidth = (int)(screenWidth * progress);

  // バーの幅が変わっていない場合は再描画しない
  if (barWidth == lastBarWidth && lastBarWidth >= 0)
  {
    return;
  }

  // バーの色を設定(タイマーの種類により変更)
  uint16_t barColor;
  if (lastTriggered == ORIENT_TOP_DOWN)
  {
    // 3分タイマー:明るい水色
    barColor = M5.Lcd.color565(100, 220, 255);
  }
  else if (lastTriggered == ORIENT_BOTTOM_DOWN)
  {
    // 5分タイマー:明るい黄緑色
    barColor = M5.Lcd.color565(200, 255, 100);
  }
  else if (lastTriggered == ORIENT_RIGHT_DOWN)
  {
    // 5秒タイマー:薄い紫
    barColor = M5.Lcd.color565(200, 150, 255);
  }
  else
  {
    barColor = TFT_DARKGREY;
  }

  // 差分描画:前回より短くなった部分だけ黒で塗る
  if (lastBarWidth > barWidth && lastBarWidth >= 0)
  {
    // 減った部分だけを黒で塗る
    M5.Lcd.fillRect(barX + barWidth, barY, lastBarWidth - barWidth, barHeight, TFT_BLACK);
  }
  // 前回より長くなった場合(リセット時など)
  else if (lastBarWidth < barWidth)
  {
    // 増えた部分だけを描画
    if (lastBarWidth >= 0)
    {
      M5.Lcd.fillRect(barX + lastBarWidth, barY, barWidth - lastBarWidth, barHeight, barColor);
    }
    else
    {
      // 初回描画
      M5.Lcd.fillRect(barX, barY, barWidth, barHeight, barColor);
    }
  }

  // 枠線は初回のみ描画(バーと同じ色)
  if (lastBarWidth < 0)
  {
    M5.Lcd.drawRect(barX, barY, screenWidth, barHeight, barColor);
  }

  lastBarWidth = barWidth;
}

// プログレスバーをリセット(画面切り替え時に呼ぶ)
void resetProgressBar()
{
  lastBarWidth = -1;
}

void showHeader()
{
  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(8, 8);
  M5.Lcd.setTextColor(TFT_WHITE, TFT_BLACK);
  M5.Lcd.print("M5 GraviTimer");
}

void showAccel(float ax, float ay, float az)
{
  M5.Lcd.setTextSize(1);
  M5.Lcd.setCursor(8, 220);
  M5.Lcd.setTextColor(TFT_YELLOW, TFT_BLACK);
  char buf[80];
  snprintf(buf, sizeof(buf), "ax:%.1f ay:%.1f az:%.1f  ", ax, ay, az);
  M5.Lcd.print(buf);
}

void showStatus(const char *s)
{
  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(8, 80);
  M5.Lcd.setTextColor(TFT_CYAN, TFT_BLACK);
  M5.Lcd.print("                    "); // clear area
  M5.Lcd.setCursor(8, 80);
  M5.Lcd.print(s);
}

void showTimeMs(unsigned long ms)
{
  unsigned long totalSeconds = ms / 1000;
  unsigned int minutes = totalSeconds / 60;
  unsigned int seconds = totalSeconds % 60;
  unsigned int msPart = (ms % 1000) / 10; // 10ms単位で表示

  // 右側面が下の時は文字サイズを調整
  if (lastTriggered == ORIENT_RIGHT_DOWN)
  {
    M5.Lcd.setTextSize(4); // 縦向きの時は少し小さくする
  }
  else
  {
    M5.Lcd.setTextSize(6);
  }

  // タイマーの種類によって色を変更
  if (lastTriggered == ORIENT_TOP_DOWN)
  {
    M5.Lcd.setTextColor(TFT_CYAN, TFT_BLACK); // 3分タイマー:水色
  }
  else if (lastTriggered == ORIENT_BOTTOM_DOWN)
  {
    M5.Lcd.setTextColor(TFT_GREENYELLOW, TFT_BLACK); // 5分タイマー:黄緑色
  }
  else if (lastTriggered == ORIENT_RIGHT_DOWN)
  {
    M5.Lcd.setTextColor(TFT_VIOLET, TFT_BLACK); // 5秒タイマー:紫
  }
  else
  {
    M5.Lcd.setTextColor(TFT_WHITE, TFT_BLACK); // デフォルト:白
  }

  M5.Lcd.setCursor(8, 130);
  char buf[32];
  snprintf(buf, sizeof(buf), "%dm%02d.%02ds   ", minutes, seconds, msPart);
  M5.Lcd.print(buf);
}

void startTimerSeconds(unsigned long seconds, Orientation src)
{
  startMillis = millis();
  elapsed = 0;
  prevElapsed = 0;
  running = true;
  lastTriggered = src;

  // 向きに応じて画面の回転を設定
  if (src == ORIENT_TOP_DOWN)
  {
    M5.Lcd.setRotation(3); // 上端が下の時(180度回転)
  }
  else if (src == ORIENT_RIGHT_DOWN)
  {
    M5.Lcd.setRotation(0); // 右側面が下の時(270度回転)
  }
  else
  {
    M5.Lcd.setRotation(1); // 通常表示(ボタン側が下の時)
  }

  // 画面をクリアして再描画
  M5.Lcd.fillScreen(TFT_BLACK);
  showHeader();

  // プログレスバーをリセットして初期描画
  resetProgressBar();
  drawProgressBar(seconds * 1000UL, seconds * 1000UL);
}

void stopTimerReset()
{
  running = false;
  elapsed = 0;
  startMillis = 0;
  prevElapsed = 0;
  lastTriggered = ORIENT_NONE;

  // 画面回転を通常に戻す
  M5.Lcd.setRotation(1);
  M5.Lcd.fillScreen(TFT_BLACK);
  resetProgressBar(); // プログレスバーをリセット
  showHeader();
  showTimeMs(0);
}

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

  M5.Lcd.setRotation(1); // 初期画面の回転設定(横向き、ボタンが下)
  M5.Lcd.fillScreen(TFT_BLACK);
  showHeader();
  showTimeMs(0);
  showStatus("Waiting...");
  delay(200);
}

void loop()
{
  M5.update();

  // 手動操作
  // A: 手動で 5 分開始(動作テスト用) / C: リセット
  if (M5.BtnA.wasPressed())
  {
    if (!running)
    {
      startTimerSeconds(DURATION_BOTTOM_SEC, ORIENT_BOTTOM_DOWN);
      showStatus("Manual start 5min");
    }
  }
  if (M5.BtnC.wasPressed())
  {
    stopTimerReset();
    showStatus("Reset");
  }

  // 向き検出
  Orientation o = detectOrientation();

  // 状態遷移
  if (o != currentDetected)
  {
    currentDetected = o;
    orientDetectStart = millis();
  }
  else
  {
    unsigned long holdTime = millis() - orientDetectStart;

    if (o != ORIENT_NONE)
    {
      // タイマーが動いていない場合:0.5秒で開始
      if (!running && holdTime >= ORIENT_HOLD_MS)
      {
        if (o != lastTriggered)
        {
          if (o == ORIENT_BOTTOM_DOWN)
          {
            startTimerSeconds(DURATION_BOTTOM_SEC, ORIENT_BOTTOM_DOWN);
            showStatus("5min");
          }
          else if (o == ORIENT_TOP_DOWN)
          {
            startTimerSeconds(DURATION_TOP_SEC, ORIENT_TOP_DOWN);
            showStatus("3min");
          }
          else if (o == ORIENT_RIGHT_DOWN)
          {
            startTimerSeconds(DURATION_RIGHT_SEC, ORIENT_RIGHT_DOWN);
            showStatus("5sec");
          }
        }
      }
      // タイマーが動作中で反対の向きを検出:2秒でリセット&再開始
      else if (running && o != lastTriggered && holdTime >= ORIENT_RESET_MS)
      {
        if (o == ORIENT_BOTTOM_DOWN)
        {
          stopTimerReset();
          startTimerSeconds(DURATION_BOTTOM_SEC, ORIENT_BOTTOM_DOWN);
          showStatus("5min");
        }
        else if (o == ORIENT_TOP_DOWN)
        {
          stopTimerReset();
          startTimerSeconds(DURATION_TOP_SEC, ORIENT_TOP_DOWN);
          showStatus("3min");
        }
        else if (o == ORIENT_RIGHT_DOWN)
        {
          stopTimerReset();
          startTimerSeconds(DURATION_RIGHT_SEC, ORIENT_RIGHT_DOWN);
          showStatus("5sec");
        }
      }
      // 待機中の表示
      else if (running && o != lastTriggered && holdTime < ORIENT_RESET_MS)
      {
        char buf[32];
        snprintf(buf, sizeof(buf), "Wait %.1fs...", (ORIENT_RESET_MS - holdTime) / 1000.0);
        showStatus(buf);
      }
    }
  }

  // タイマー更新(残り時間を表示)
  if (running)
  {
    elapsed = millis() - startMillis;
    unsigned long durationMs = 0;
    if (lastTriggered == ORIENT_BOTTOM_DOWN)
      durationMs = DURATION_BOTTOM_SEC * 1000UL;
    else if (lastTriggered == ORIENT_TOP_DOWN)
      durationMs = DURATION_TOP_SEC * 1000UL;
    else if (lastTriggered == ORIENT_RIGHT_DOWN)
      durationMs = DURATION_RIGHT_SEC * 1000UL;

    if (elapsed >= durationMs && durationMs > 0)
    {
      running = false;
      elapsed = durationMs;
      showTimeMs(0);                  // 終了時は 0 を表示
      drawProgressBar(0, durationMs); // プログレスバーを空にする

      // 終了通知: 文字を5回点滅
      for (int i = 0; i < 5; i++)
      {
        // 右側面の時は文字サイズ調整
        if (lastTriggered == ORIENT_RIGHT_DOWN)
        {
          M5.Lcd.setTextSize(3);
          M5.Lcd.setCursor(8, 130);
          M5.Lcd.setTextColor(TFT_RED, TFT_BLACK);
          M5.Lcd.print("TIME UP!");
        }
        else
        {
          M5.Lcd.setTextSize(5);
          M5.Lcd.setCursor(8, 130);
          M5.Lcd.setTextColor(TFT_RED, TFT_BLACK);
          M5.Lcd.print("TIME UP!     ");
        }
        delay(200);

        // 点滅OFF
        M5.Lcd.fillRect(8, 130, 320, 60, TFT_BLACK);
        delay(200);
      }

      // 終了表示
      if (lastTriggered == ORIENT_RIGHT_DOWN)
      {
        M5.Lcd.setTextSize(3);
        M5.Lcd.setCursor(8, 130);
        M5.Lcd.setTextColor(TFT_CYAN, TFT_BLACK);
        M5.Lcd.print("Finished!");
      }
      else
      {
        M5.Lcd.setTextSize(5);
        M5.Lcd.setCursor(8, 130);
        M5.Lcd.setTextColor(TFT_CYAN, TFT_BLACK);
        M5.Lcd.print("Finished!    ");
      }
    }
    else
    {
      // 残り時間表示
      unsigned long remain = (durationMs > elapsed) ? (durationMs - elapsed) : 0;
      showTimeMs(remain);
      drawProgressBar(remain, durationMs); // プログレスバーを更新
    }
  }
  else
  {
    // タイマー停止中はプログレスバーを非表示または初期状態
    drawProgressBar(0, 1); // 空のバーを表示
  }

  // 加速度をデバッグ用に目立たない大きさで表示
  float ax = 0, ay = 0, az = 0;
  if (M5.Imu.getAccelData(&ax, &ay, &az))
  {
    normalizeToG(ax, ay, az);
    showAccel(ax, ay, az);
  }

  delay(50);
}

platformio.ini

INI
[env:m5stack-core2]
platform = espressif32
board = m5stack-core2
framework = arduino
lib_deps = m5stack/M5Unified@^0.2.7
monitor_speed = 115200

Credits

Yosuke Toyota
1 project • 5 followers

Comments