息子の参観日で RFID で宝さがしができる仕掛けを見せてきました

息子の参観日で RFID で宝さがしができる仕掛けを見せてきました

息子の参観日で RFID で宝さがしができる仕掛けを見せてきました。自分のまとめです。

この記事は M5Stack Advent Calendar 2025 の 21 日目の記事として書かせていただきました。

Fun・Done・Learn 形式でまとめてみます。開発経緯を書きつつも実際のソースコードなどの知見も置いております。

Fun たのしかったこと

うちの幼稚園の息子の参観日はガチなので、息子を親が参観するだけでなく、親から園児たちに何かを見せるステージタイムもあり、年少・年中ではこんな展示をしてきました。

今回は AtomS3 と Unit RFID 2 でひとまず UUID とタイプ判定を実装してみたメモ を活用しての宝探しの仕組みを作りました。

このように、

宝箱を用意します。妻がつくりました。素敵。

このように、シール状の RFID タグをとりつけます。はずれのものにもダミーのシールを張り付けて分かりにくくしてます。

これを園児に渡してかざして探すとアタリが出たら、宝石マークが出てピロリピロリと音が鳴ります!

当日は、妻と私で披露しましたが、園児も楽しんでいてうれしかったです。

Done やれたこと

やれたこととしては、妻と協力しながら、私は RFID リーダーを使いやすくするかというところで、

  • バッテリーがあり園児にも持ちやすい形状にすること
  • 親しみやすい形状として LEGO を使うこと
  • あたったときのアニメーションも楽しくすること
  • なるべく反応をよくすること

などなどがんばりました。

あまりデバイスだけの楽しさにせず、当たるとキラキラした宝石がもらえるという分かりやすい流れにもできました。

妻がめっちゃ良い宝石シールを見つけてくれて「これでいける!」と思いました。こういう場の設定を考えてくれるからこそ、デバイス実装が活きるので、ほんと妻とコラボレーションできてよかったです。

RFID についてもカード型だけでなくコイン型、今回使ったシール型もあって、以前から持っていたものもあって、ようやく日の目を見たところがあります。

いやー、しかし、シール型がめっちゃ工作に仕込みやすくて、また何か作ってみたくなりました。

Learn まなんだこと

何より学んだこととしては LEGO で園児が触りやすいように作るのがとてもやりやすかったことが学びです。ともあれ Atomic Motion に LEGO 穴があることが素晴らしいのですが、比較的しっかりしたつくりで、少し壊れてもすぐに直せる仕組みは当日も安心感がありました。

今回は IoT 機能は入れずに、デバイスと RFID ユニットで完結できるようにがんばっていろいろと学びがありました。

使ったもののお品書きです。Atomic Motion がそのまま手持ちにできたのは良かったですね。あれでだいぶ作るべき場所が少なくなりました。

それ以外に、こんな実装もしました。いろいろやったなあ。

  • RFID カード読み取り基礎
    • MFRC522 センサーで RFID カードの UID を検出
    • 5つの登録済み UID(ホワイトリスト)と照合
    • AtomicMotion経由での I2C 通信
  • カード認証結果の表示
    • 当たりの時
      • 有効な UID ダイヤモンドのグラフィックを表示(キラキラアニメーション付き)
    • はずれの時
      • (-_-) の顔文字を表示 → 今回は使わず
    • 待機中
      • 子供にもわかりやすく「?」マークを表示
  • サウンド再生
    • 当たり検出時に「ド・ミ・ソ」の和音を3回鳴らす
    • 非ブロッキング処理で画面更新と並行動作
  • バッテリー残量表示
    • 電圧から残量をパーセント計算
    • 残量に応じて色分け(緑→黄→赤)
  • 状態管理
    • IDLE 待機中
    • DETECTING カード検出中(150ms継続で確定)
    • CONFIRMED: 認証確定
    • REMOVED: カード離脱検出(300msで離脱判定)

園児の操作は良い意味で容赦がないので、けっこう UI というか状態管理とか非ブロッキングを駆使しして待たせないように頑張りました。

実際のソースコードです。

#include <M5Unified.h>
#include <M5AtomicMotion.h>
#include <MFRC522_I2C.h>

M5AtomicMotion AtomicMotion;
MFRC522_I2C mfrc522(0x28, -1);

// AtomMotion用I2Cピン
const uint8_t MOTION_SDA = 38;
const uint8_t MOTION_SCL = 39;

// Speaker用ピン(ポートB)
const uint8_t BUZZER_PIN = 8;
const int BUZZER_CHANNEL = 0;
const int RESOLUTION = 10;

// 音階の周波数(ド・ミ・ソ)
const int FREQ_C = 523;  // ド (C5)
const int FREQ_E = 659;  // ミ (E5)
const int FREQ_G = 784;  // ソ (G5)

// 登録UID(最大4つ)
// 5A91460C4F4189
// 5A41C2074F4189
// 5A113D094F4189
// 5A31CD0A4F4189
// 5AA1460C4F4189

const String uidString1 = "5A91460C4F4189";
const String uidString2 = "5A41C2074F4189";
const String uidString3 = "5A113D094F4189";
const String uidString4 = "5A31CD0A4F4189";
const String uidString5 = "5AA1460C4F4189";

// カード検出の状態定義(今は使うだけ)
enum CardState {
  STATE_IDLE,       // 待機中(カードなし)
  STATE_DETECTING,  // 検出中(確定前)
  STATE_CONFIRMED,  // 確定(演出可能)
  STATE_REMOVED     // 離脱検出
};

// グローバル変数
const String versionMessage = "v1.11";
int batteryPercent = 0;
CardState currentState = STATE_IDLE;  // 状態変数
String confirmedUID = "";
String detectingUID = "";
unsigned long stateStartTime = 0;
unsigned long lastDetectTime = 0;

// 和音再生用の変数
bool isPlayingChord = false;  // 和音再生中フラグ
int chordCount = 0;           // 再生回数(0-2)
int noteIndex = 0;            // 音符インデックス(0:ド, 1:ミ, 2:ソ)
unsigned long noteStartTime = 0;
bool isNotePlaying = false;

// アニメーション用の変数
int diamondAnimFrame = 0;           // アニメーションフレーム(0-2)
unsigned long lastAnimUpdate = 0;   // 最後にアニメーション更新した時刻
const unsigned long ANIM_INTERVAL = 150;  // アニメーション更新間隔(150ms)

// タイミング定数
const unsigned long CONFIRM_THRESHOLD = 150;  // 150ms連続検出で確定
const unsigned long REMOVE_THRESHOLD = 300;   // 300ms非検出で離脱
const unsigned long NOTE_DURATION = 80;       // 音符の長さ(100→80に短縮)
const unsigned long NOTE_GAP = 20;            // 音符間の間隔(30→20に短縮)
const unsigned long CHORD_GAP = 30;           // 和音セット間の間隔(50→30に短縮)

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  
  M5.Display.setRotation(2);
  M5.Display.fillScreen(BLACK);
  M5.Display.setTextSize(1);
  M5.Display.println("Initializing...");
  
  // Speaker初期化
  ledcSetup(BUZZER_CHANNEL, FREQ_C, RESOLUTION);
  ledcAttachPin(BUZZER_PIN, BUZZER_CHANNEL);
  ledcWrite(BUZZER_CHANNEL, 0);
  
  // RFID用のI2C初期化
  Wire.begin();
  mfrc522.PCD_Init();
  
  // AtomicMotionの初期化
  while (!AtomicMotion.begin(&Wire1, M5_ATOMIC_MOTION_I2C_ADDR, MOTION_SDA, MOTION_SCL, 100000)) {
    M5.Display.println("Motion Failed");
    delay(1000);
  }
  
  M5.Display.println("Ready!");
  delay(500);
  M5.Display.clear();
}

void updateBattery() {
  float voltage = AtomicMotion.ina226.readBusVoltage();
  
  if (voltage >= 4.2) {
    batteryPercent = 100;
  } else if (voltage <= 3.0) {
    batteryPercent = 0;
  } else {
    batteryPercent = (int)((voltage - 3.0) / 1.2 * 100);
  }
}

void displayBattery() {
  int currentX = M5.Display.getCursorX();
  int currentY = M5.Display.getCursorY();
  
  M5.Display.setCursor(0, 0);
  M5.Display.setTextSize(2);
  
  if (batteryPercent > 50) {
    M5.Display.setTextColor(GREEN);
  } else if (batteryPercent > 20) {
    M5.Display.setTextColor(YELLOW);
  } else {
    M5.Display.setTextColor(RED);
  }
  M5.Display.printf("%d%%", batteryPercent);
  M5.Display.setTextColor(WHITE);
  
  // バージョン表示(右上)
  int screenW = M5.Display.width();
  M5.Display.setCursor(screenW - 60, 0);
  M5.Display.setTextSize(2);
  M5.Display.print(versionMessage);
  
  M5.Display.setCursor(currentX, currentY);
}

void playChord() {
  // ド・ミ・ソを3回鳴らす
  for (int i = 0; i < 3; i++) {
    // ド
    ledcSetup(BUZZER_CHANNEL, FREQ_C, RESOLUTION);
    ledcWrite(BUZZER_CHANNEL, 512);
    delay(100);
    ledcWrite(BUZZER_CHANNEL, 0);
    delay(30);
    
    // ミ
    ledcSetup(BUZZER_CHANNEL, FREQ_E, RESOLUTION);
    ledcWrite(BUZZER_CHANNEL, 512);
    delay(100);
    ledcWrite(BUZZER_CHANNEL, 0);
    delay(30);
    
    // ソ
    ledcSetup(BUZZER_CHANNEL, FREQ_G, RESOLUTION);
    ledcWrite(BUZZER_CHANNEL, 512);
    delay(100);
    ledcWrite(BUZZER_CHANNEL, 0);
    delay(50);
  }
}

// 和音再生を開始
void startChord() {
  isPlayingChord = true;
  chordCount = 0;
  noteIndex = 0;
  isNotePlaying = true;  // すぐに再生開始
  noteStartTime = millis();
  
  // 最初の音(ド)をすぐ鳴らす
  ledcSetup(BUZZER_CHANNEL, FREQ_C, RESOLUTION);
  ledcWrite(BUZZER_CHANNEL, 512);
}

// 和音再生を更新(非ブロッキング)
void updateChord() {
  if (!isPlayingChord) return;
  
  unsigned long now = millis();
  
  // 3回再生完了したら終了
  if (chordCount >= 3) {
    isPlayingChord = false;
    ledcWrite(BUZZER_CHANNEL, 0);
    return;
  }
  
  if (isNotePlaying) {
    // 音符再生中 - 100ms経過したら止める
    if (now - noteStartTime >= NOTE_DURATION) {
      ledcWrite(BUZZER_CHANNEL, 0);
      noteIndex++;
      isNotePlaying = false;
      noteStartTime = now;  // 間隔計測開始
    }
  } else {
    // 音と音の間 - 次の音を開始するタイミングか?
    unsigned long gap = (noteIndex == 0 && chordCount > 0) ? CHORD_GAP : NOTE_GAP;
    
    if (now - noteStartTime >= gap) {
      if (noteIndex > 2) {
        // ド・ミ・ソの1セット完了
        noteIndex = 0;
        chordCount++;
        if (chordCount >= 3) return;  // 完了
      }
      
      // 次の音を鳴らす
      int freq;
      switch (noteIndex) {
        case 0: freq = FREQ_C; break;  // ド
        case 1: freq = FREQ_E; break;  // ミ
        case 2: freq = FREQ_G; break;  // ソ
        default: return;
      }
      
      ledcSetup(BUZZER_CHANNEL, freq, RESOLUTION);
      ledcWrite(BUZZER_CHANNEL, 512);
      noteStartTime = now;
      isNotePlaying = true;
    }
  }
}

void drawDiamond(int cx, int cy, int size, int animFrame) {
  // SVGのダイヤモンド構造を128x128画面に収める
  // animFrame: 0-2 でキラキラアニメーション
  
  // 主要な座標
  int topY = 34;           // 上辺のY座標
  int middleY = 64;        // 中央水平線(ガードル)のY座標
  int bottomY = 124;       // 下頂点のY座標(2倍に延長)
  
  // 上辺の座標
  int topLeft = 34;        // 上辺左端
  int topRight = 94;       // 上辺右端
  
  // 上部天面の分割線の座標
  int topDivLeft = 43;     // 上辺での左分割点
  int topDivRight = 85;    // 上辺での右分割点
  
  // 中央水平線(ガードル)の座標
  int midLeft = 10;        // ガードル左端
  int midDivLeft = 38;     // ガードル左分割点
  int midDivRight = 90;    // ガードル右分割点
  int midRight = 118;      // ガードル右端
  
  int centerX = 64;        // 中心X座標
  
  // 色の定義
  uint16_t lightCyan = M5.Display.color565(180, 230, 255);  // 薄い水色
  uint16_t cyan = M5.Display.color565(0, 200, 255);         // 水色
  uint16_t blue = M5.Display.color565(0, 100, 200);         // 青
  
  // アニメーションフレームで色パターンを切り替え
  uint16_t topLeftColor, topCenterColor, topRightColor;
  uint16_t bottomLeftColor, bottomCenterColor, bottomRightColor;
  
  if (animFrame == 0) {
    // フレーム0: 薄い水色・白・薄い水色 / 水色・青・水色
    topLeftColor = lightCyan;
    topCenterColor = WHITE;
    topRightColor = lightCyan;
    bottomLeftColor = cyan;
    bottomCenterColor = blue;
    bottomRightColor = cyan;
  } else if (animFrame == 1) {
    // フレーム1: 白・薄い水色・白 / 青・水色・青
    topLeftColor = WHITE;
    topCenterColor = lightCyan;
    topRightColor = WHITE;
    bottomLeftColor = blue;
    bottomCenterColor = cyan;
    bottomRightColor = blue;
  } else {
    // フレーム2: 薄い水色・白・薄い水色 / 水色・青・水色(元に戻る)
    topLeftColor = lightCyan;
    topCenterColor = WHITE;
    topRightColor = lightCyan;
    bottomLeftColor = cyan;
    bottomCenterColor = blue;
    bottomRightColor = cyan;
  }
  
  // 上部3面を塗りつぶし
  // 左面
  M5.Display.fillTriangle(topLeft, topY, topDivLeft, topY, midDivLeft, middleY, topLeftColor);
  M5.Display.fillTriangle(topLeft, topY, midLeft, middleY, midDivLeft, middleY, topLeftColor);
  
  // 中央面
  M5.Display.fillTriangle(topDivLeft, topY, topDivRight, topY, midDivRight, middleY, topCenterColor);
  M5.Display.fillTriangle(topDivLeft, topY, midDivLeft, middleY, midDivRight, middleY, topCenterColor);
  
  // 右面
  M5.Display.fillTriangle(topDivRight, topY, topRight, topY, midDivRight, middleY, topRightColor);
  M5.Display.fillTriangle(topRight, topY, midRight, middleY, midDivRight, middleY, topRightColor);
  
  // 下部3面を塗りつぶし
  // 左面
  M5.Display.fillTriangle(midLeft, middleY, midDivLeft, middleY, centerX, bottomY, bottomLeftColor);
  
  // 中央面
  M5.Display.fillTriangle(midDivLeft, middleY, midDivRight, middleY, centerX, bottomY, bottomCenterColor);
  
  // 右面
  M5.Display.fillTriangle(midDivRight, middleY, midRight, middleY, centerX, bottomY, bottomRightColor);
  
  // 外側の輪郭を描く(線を後から描いて強調)
  M5.Display.drawLine(topLeft, topY, topRight, topY, WHITE);      // 上辺
  M5.Display.drawLine(topRight, topY, midRight, middleY, WHITE);  // 右上の辺
  M5.Display.drawLine(midRight, middleY, centerX, bottomY, WHITE); // 右下の辺
  M5.Display.drawLine(centerX, bottomY, midLeft, middleY, WHITE);  // 左下の辺
  M5.Display.drawLine(midLeft, middleY, topLeft, topY, WHITE);    // 左上の辺
  
  // 上部天面の分割線(ハの字)
  M5.Display.drawLine(topDivLeft, topY, midDivLeft, middleY, WHITE);   // 左のハの字
  M5.Display.drawLine(topDivRight, topY, midDivRight, middleY, WHITE); // 右のハの字
  
  // 中央の水平線(ガードル)
  M5.Display.drawLine(midLeft, middleY, midRight, middleY, WHITE);
  
  // 下部への放射線(4本)
  M5.Display.drawLine(midLeft, middleY, centerX, bottomY, WHITE);      // 左端から
  M5.Display.drawLine(midDivLeft, middleY, centerX, bottomY, WHITE);   // 左分割点から
  M5.Display.drawLine(midDivRight, middleY, centerX, bottomY, WHITE);  // 右分割点から
  M5.Display.drawLine(midRight, middleY, centerX, bottomY, WHITE);     // 右端から
}

bool isValidCard(String uid) {
  if (uidString1 != "" && uid == uidString1) return true;
  if (uidString2 != "" && uid == uidString2) return true;
  if (uidString3 != "" && uid == uidString3) return true;
  if (uidString4 != "" && uid == uidString4) return true;
  if (uidString5 != "" && uid == uidString5) return true;
  return false;
}

// 音を停止する関数
void stopSound() {
  ledcWrite(BUZZER_CHANNEL, 0);
  isPlayingChord = false;
  chordCount = 0;
  noteIndex = 0;
  isNotePlaying = false;
}

// 状態を変更する関数
void changeState(CardState newState) {
  currentState = newState;
  stateStartTime = millis();
}

void loop() {
  M5.update();
  updateBattery();
  updateChord();  // 和音再生を更新
  
  unsigned long now = millis();
  bool cardDetected = false;
  String detectedUID = "";
  
  // カード検出処理
  if (mfrc522.PICC_IsNewCardPresent() && mfrc522.PICC_ReadCardSerial()) {
    cardDetected = true;
    lastDetectTime = now;
    
    for (byte i = 0; i < mfrc522.uid.size; i++) {
      if (mfrc522.uid.uidByte[i] < 0x10) detectedUID += "0";
      detectedUID += String(mfrc522.uid.uidByte[i], HEX);
    }
    detectedUID.toUpperCase();
  }
  
  // === 状態機械(元のロジックを状態で表現) ===
  switch (currentState) {
    
    case STATE_IDLE:
      // 待機中 - カード待ち
      if (cardDetected) {
        detectingUID = detectedUID;
        changeState(STATE_DETECTING);
      } else {
        // 待機画面表示(元のコードと同じ)
        static unsigned long lastUpdate = 0;
        if (now - lastUpdate > 500) {
          lastUpdate = now;
          
          M5.Display.clear();
          displayBattery();
          
          int screenW = M5.Display.width();
          int screenH = M5.Display.height();
          M5.Display.setTextSize(7);
          M5.Display.setTextColor(WHITE);
          M5.Display.setCursor((screenW - 42) / 2, (screenH - 56) / 2);
          M5.Display.println("?");
        }
      }
      break;
    
    case STATE_DETECTING:
      // 検出中 - 確定待ち
      if (cardDetected) {
        if (detectedUID == detectingUID) {
          // 同じカードを継続検出
          if (now - stateStartTime >= CONFIRM_THRESHOLD) {
            // 確定!
            confirmedUID = detectingUID;
            changeState(STATE_CONFIRMED);
            
            // 画面表示(元のコードと同じ)
            M5.Display.fillScreen(BLACK);
            displayBattery();
            
            int screenW = M5.Display.width();
            int screenH = M5.Display.height();
            int centerX = screenW / 2;
            int centerY = screenH / 2;
            
            if (isValidCard(confirmedUID)) {
              // OK - ダイヤモンドを描画
              drawDiamond(centerX, centerY, 40, 0);  // 初回はフレーム0
              startChord();  // 非ブロッキングで和音開始
              diamondAnimFrame = 0;  // アニメーションフレームをリセット
              lastAnimUpdate = now;  // アニメーションタイマーを初期化
            } else {
              // NG
              M5.Display.setTextSize(3);
              M5.Display.setTextColor(WHITE);
              M5.Display.setCursor(10, centerY - 12);
              M5.Display.println("(-_-)");
            }
          }
        } else {
          // 別のカードに変わった
          detectingUID = detectedUID;
          changeState(STATE_DETECTING);
          stopSound();  // 念のため音を止める
        }
      } else {
        // 検出が途切れた
        if (now - lastDetectTime >= REMOVE_THRESHOLD) {
          changeState(STATE_IDLE);
          stopSound();  // 音を止める
        }
      }
      break;
    
    case STATE_CONFIRMED:
      // 確定状態 - カードかざし中
      if (cardDetected) {
        if (detectedUID != confirmedUID) {
          // 別のカードに変わった → 新しいカード検出開始
          detectingUID = detectedUID;
          changeState(STATE_DETECTING);
          stopSound();  // 音を即座に止める!
        } else {
          // 同じカードを継続検出 - 有効なカードの場合のみキラキラアニメーション
          if (isValidCard(confirmedUID)) {
            if (now - lastAnimUpdate >= ANIM_INTERVAL) {
              lastAnimUpdate = now;
              diamondAnimFrame = (diamondAnimFrame + 1) % 3;  // 0→1→2→0...
              
              // ダイヤモンドを再描画
              M5.Display.fillScreen(BLACK);
              displayBattery();
              
              int screenW = M5.Display.width();
              int screenH = M5.Display.height();
              int centerX = screenW / 2;
              int centerY = screenH / 2;
              
              drawDiamond(centerX, centerY, 40, diamondAnimFrame);
            }
          }
          // はずれカードの場合は何もしない((-_-)表示のまま)
        }
      } else {
        // カードが離れた
        if (now - lastDetectTime >= REMOVE_THRESHOLD) {
          changeState(STATE_REMOVED);
          stopSound();  // 音を止める
        }
      }
      break;
    
    case STATE_REMOVED:
      // 離脱状態
      if (cardDetected) {
        // 新しいカード
        detectingUID = detectedUID;
        changeState(STATE_DETECTING);
      } else {
        // しばらくしたらIDLEへ戻る
        if (now - lastDetectTime >= 500) {
          changeState(STATE_IDLE);
        }
      }
      break;
  }
  
  // バッテリー表示の定期更新
  static unsigned long lastBatteryUpdate = 0;
  if (now - lastBatteryUpdate > 500) {
    lastBatteryUpdate = now;
    M5.Display.fillRect(0, 0, 128, 20, BLACK);
    displayBattery();
  }
  
  delay(50);
}

そして、一番頑張ったのはダイヤモンドの描画とアニメーションかもしれません。

これ、うまくいっているように見えるのですが、実際は私が SVG でダイヤモンドを書き起こして伝えたのが効いたというところで、やはり AI には視覚というものはないよなというのを改めて感じたのも学びでした。

ちなみに演目を行う時間も限られているので複数台ないといけないねとなり、私の今までのデバイスストックをフルパワーで対応できました。でもそれでも園児全員をうまく回すには 5 台あったほうがよかったのでギリギリでした。

いろいろ妻と私で準備した結果、当日はとてもスムーズに見せることができ、園児のみなさんも楽しんでくれましたし、なにより、息子がとってもうれしそうでした。当日までちゃんと秘密にしててえらかったね。いろいろ検証につきあってくれてありがとうね。

年少の 1 年目からピカピカする仕組みをぶっこみ、年中の 2 年目は車と AI の仕組みをぶっこみ、ハードルが上がる中、今回の宝探しの仕組みにも着地でき、カジュアルにデジタル仕組み作るパパ・ママとしてのポテンシャルを 3 年間提示し続けられてよかったです。