概要
マイコンとミリ波レーダモジュールを使って非接触で心拍数を計測、心拍数と連動したLED明滅とモータ振動により胸のドキドキを視える化・聴こえる化し、さらに心拍数の時系列データをAIで解析することによりドキドキに応じたメッセージを作成してくれるIoTプロトタイプを作成しました!
ドキドキの視える化・聴こえる化により、お互いの気持ちを共有したり、出会いや恋愛などの場面で緊張を和らげたりするのに役立ちます!またドキドキAI判定により、自分でも気づかなかった心の変化に気づくきっかけになります!
完成品の動画像
①ドキドキ探知とドキドキの共有の様子
②実際に送信されたメッセージ (AI解析結果の例:LINE通知、メール通知)
・LINE通知

・メール通知
測定ボタンを押して10秒間の心拍数(ドキドキ)の変化からAI解析。背中を押してくれるようなメッセージがメールやLINEで届きます。
製作の動機
①私は見た目の印象から「落ち着いている人」と思われがちですが、実は内心ドキドキしていることも多く、外見とのギャップを感じることがあります。もしかすると、程度の差こそあれ“隠れドキドキ”は私だけでなく、他の人にもあるのかもしれません。ドキドキを共有し合える手段があればと思いこのようなプロトタイプを作りたいと思いました。
②もうひとつ理由があります。それはドキドキして迷ってしまう瞬間に、そっと背中を“えいっ”と押してくれるようなものがあればと思うことがあります。逆に、誰かが迷っているときには、同じように小さな勇気を渡せる存在でありたいとも感じています。そんな「一歩踏み出すきっかけ」を形にしたくて、このドキドキ探知機を作りたいと思いました。
構成
ミリ波レーダモジュールで心拍を非接触計測し、そのデータを Xiao ESP32 で処理します。心拍信号に合わせて、ハックした金属探知機内蔵の振動モータを 同期駆動 させてドキドキを“聴こえる化”し、同時に接続した LED を 同期明滅 させてドキドキを“視える化”します。また、リアルタイムの心拍数などを確認できるよう、LCD に表示しています。電源は金属探知機内蔵の 9V 電池を利用し、DC-DC コンバータで マイコン用の 5V を生成しております。
ボタンを押すと、直近10秒間の心拍データを生成AIへ送り、ドキドキ判定を行います。判定時のプロンプトには、「背中をえいっと押してあげる」ようなメッセージを返す指示を入れています。なおアプリケーションの構築には SORACOM Flux を用い、ローコードで実装しております。
材料
・XIAO 60GHzミリ波レーダモジュール: MR60BHA2
・金属探知機
・DC‐DCコンバータ
・有機ELディスプレイ(OLED):SSD1306
・3Pトグルスイッチ(1回路2接点)
・熱収縮チューブ
全体プログラム
コードはコチラ
#include <Arduino.h>
#include "Seeed_Arduino_mmWave.h"
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
// --- WiFi設定 ---
const char* ssid = "*******";
const char* password = "*******";
const char* flux_url = "https://api.soracom.io/v1/flux/incoming_webhooks/*************/**************";
// --- OLED設定 ---
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// --- LED & BUTTONピン設定 ---
#define LED_PIN 2
#define BUTTON_PIN 1
#define SAMPLE_COUNT 100
// --- mmWaveセンサ用 ---
#ifdef ESP32
HardwareSerial mmWaveSerial(0);
#else
#define mmWaveSerial Serial1
#endif
SEEED_MR60BHA2 mmWave;
// --- センサデータ構造体 ---
typedef struct {
float total_phase;
float breath_phase;
float heart_phase;
float breath_rate;
float heart_rate;
float distance;
} SensorData_t;
// --- FreeRTOSキュー ---
static QueueHandle_t sensorQueuePrint = nullptr;
static QueueHandle_t sensorQueueDisplay = nullptr;
static QueueHandle_t heartRateQueue = nullptr;
static QueueHandle_t sensorQueueFlux = nullptr;
// --- データバッファ ---
float heartRateBuffer[SAMPLE_COUNT];
float breathRateBuffer[SAMPLE_COUNT];
float distanceBuffer[SAMPLE_COUNT];
// --- 表示用メッセージ ---
String displayMessage = "";
unsigned long messageExpireTime = 0;
SemaphoreHandle_t displayMutex;
// --- WiFi接続 ---
void connectToWiFi() {
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi connected");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
}
// --- 測定タスク ---
void MeasureTask(void *pvParameters) {
SensorData_t data;
for (;;) {
if (mmWave.update(100)) {
mmWave.getHeartBreathPhases(data.total_phase, data.breath_phase, data.heart_phase);
mmWave.getBreathRate(data.breath_rate);
mmWave.getHeartRate(data.heart_rate);
mmWave.getDistance(data.distance);
} else {
data = {0, 0, 0, 0, 0, 0};
}
xQueueOverwrite(sensorQueuePrint, &data);
xQueueOverwrite(sensorQueueDisplay, &data);
xQueueOverwrite(sensorQueueFlux, &data);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
// --- OLED表示タスク ---
void DisplayTask(void *pvParameters) {
SensorData_t data;
for (;;) {
if (xQueueReceive(sensorQueueDisplay, &data, pdMS_TO_TICKS(100)) == pdPASS) {
if (xSemaphoreTake(displayMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.printf("Breath: %.1f bpm", data.breath_rate);
display.setCursor(0, 16);
display.printf("Heart : %.1f bpm", data.heart_rate);
display.setCursor(0, 32);
display.printf("Dist. : %.1f cm", data.distance);
if (millis() < messageExpireTime && displayMessage.length() > 0) {
display.setCursor(0, 48);
display.print(displayMessage);
}
display.display();
xSemaphoreGive(displayMutex);
}
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
// --- シリアル出力 & LED向け心拍送信タスク ---
void PrintTask(void *pvParameters) {
SensorData_t data;
for (;;) {
if (xQueueReceive(sensorQueuePrint, &data, portMAX_DELAY) == pdPASS) {
Serial.printf("total_phase: %.2f\tbreath_phase: %.2f\theart_phase: %.2f\n",
data.total_phase, data.breath_phase, data.heart_phase);
Serial.printf("breath_rate: %.2f\theart_rate: %.2f\tdistance: %.2f\n\n",
data.breath_rate, data.heart_rate, data.distance);
xQueueOverwrite(heartRateQueue, &data.heart_rate);
}
}
}
// --- LED点滅タスク(心拍同期) ---
void HeartLEDTask(void *pvParameters) {
pinMode(LED_PIN, OUTPUT);
float rate = 0.0;
for (;;) {
xQueueReceive(heartRateQueue, &rate, pdMS_TO_TICKS(100));
if (rate < 10.0 || isnan(rate) || rate > 200.0) {
digitalWrite(LED_PIN, LOW);
vTaskDelay(pdMS_TO_TICKS(100));
continue;
}
float interval = 60.0 / rate;
int delay_ms = (int)(interval * 1000 / 2);
digitalWrite(LED_PIN, HIGH);
vTaskDelay(pdMS_TO_TICKS(delay_ms));
digitalWrite(LED_PIN, LOW);
vTaskDelay(pdMS_TO_TICKS(delay_ms));
}
}
// --- ボタン押下トリガによる10秒分送信タスク ---
void ButtonTriggeredFluxTask(void *pvParameters) {
SensorData_t data;
for (;;) {
if (digitalRead(BUTTON_PIN) == LOW) {
Serial.println("Button pressed: start 10s collection");
// メッセージ表示(button pressed !)
if (xSemaphoreTake(displayMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
displayMessage = "button pressed !";
messageExpireTime = millis() + 10000;
xSemaphoreGive(displayMutex);
}
int count = 0;
while (count < SAMPLE_COUNT) {
if (xQueueReceive(sensorQueueFlux, &data, pdMS_TO_TICKS(200)) == pdPASS) {
heartRateBuffer[count] = data.heart_rate;
breathRateBuffer[count] = data.breath_rate;
distanceBuffer[count] = data.distance;
count++;
}
vTaskDelay(pdMS_TO_TICKS(100));
}
String json = "{\"heart_rate\":[";
for (int i = 0; i < SAMPLE_COUNT; i++) {
json += String(heartRateBuffer[i], 1);
if (i < SAMPLE_COUNT - 1) json += ",";
}
json += "],\"breath_rate\":[";
for (int i = 0; i < SAMPLE_COUNT; i++) {
json += String(breathRateBuffer[i], 1);
if (i < SAMPLE_COUNT - 1) json += ",";
}
json += "],\"distance\":[";
for (int i = 0; i < SAMPLE_COUNT; i++) {
json += String(distanceBuffer[i], 1);
if (i < SAMPLE_COUNT - 1) json += ",";
}
json += "]}";
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
http.begin(flux_url);
http.addHeader("Content-Type", "application/json");
int res = http.POST(json);
Serial.print("POST Flux (button): "); Serial.println(json);
Serial.print("Response: "); Serial.println(res);
http.end();
}
// メッセージ変更(data sended !)
if (xSemaphoreTake(displayMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
displayMessage = "data sended !";
messageExpireTime = millis() + 3000;
xSemaphoreGive(displayMutex);
}
vTaskDelay(pdMS_TO_TICKS(3000)); // ボタン連打防止
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
// --- セットアップ ---
void setup() {
Serial.begin(115200);
mmWave.begin(&mmWaveSerial);
if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
Serial.println("SSD1306 init failed");
while (1);
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Initializing...");
display.display();
connectToWiFi();
pinMode(BUTTON_PIN, INPUT_PULLUP);
displayMutex = xSemaphoreCreateMutex();
sensorQueuePrint = xQueueCreate(1, sizeof(SensorData_t));
sensorQueueDisplay = xQueueCreate(1, sizeof(SensorData_t));
heartRateQueue = xQueueCreate(1, sizeof(float));
sensorQueueFlux = xQueueCreate(1, sizeof(SensorData_t));
xTaskCreate(MeasureTask, "MeasureTask", 4096, nullptr, 2, nullptr);
xTaskCreate(PrintTask, "PrintTask", 4096, nullptr, 1, nullptr);
xTaskCreate(DisplayTask, "DisplayTask", 4096, nullptr, 1, nullptr);
xTaskCreate(HeartLEDTask, "LEDTask", 2048, nullptr, 1, nullptr);
xTaskCreate(ButtonTriggeredFluxTask,"BtnFluxTask", 8192, nullptr, 1, nullptr);
}
void loop() {
// FreeRTOS使用のため空
}
</details>
製作の様子
金属探知機のケースを開け、9V 電池端子にハーネスを半田付けして DC-DCコンバータへ接続します。固定用の穴を電動ドリルで開けてコンバータをネジ止めし、ハーネスを外へ出すための穴も加工して配線を引き出します。
その後、ミリ波レーダモジュール(Xiao ESP32 含む)、LCD、スイッチ、LED などを配線図に従って接続し、金属探知機へ取り付けます。
当初はブレッドボードで組んでいましたが、自分と相手を測る際に着脱を繰り返すうち、ハーネスが抜けやすいことが課題になったため、基板を起こしてコネクタ接続方式に変更しました。
工夫した点
・LCDの向きを自在に切り替えるマグネット着脱機構
自分のドキドキを測定したい時と相手のドキドキを測定したいとき、どちらの場合でもLCDが確認できるようにするため、マグネットを使ってLCDの着脱機構を実装しました。


苦労した所
・LINE Messaging API連携時にハマりました。特に文字数制限の所はなかなか気づけず、デバッグで何度か試している中で偶然短いメッセージのときだけ送られたことがあり、もしやって思い、プロンプトに文字数制限を付けたら、安定してLINEにメッセージ送信されるようになりました。
・もう一点、HttpのBodyの書き方が間違えていて、Bodyの書き方を直したら、安定してLINEにメッセージ送信されるようになりました。
・複数の問題が同時に起きていたので、問題特定に少し時間がかかりましたが、無事LINEとの連携が出来ました。

技適未取得機器を用いた実験等の特例制度
今回使用したミリ波のレーダーモジュール、FCCのマークはあるがTELECのマーク(国内技適)がありませんでした。
確認しましたところ、使用モジュールが0.00039W(周波数:59000MHz-63000MHz)と記載されており総務省告示264号を確認して出力的にも問題なさそうなので展示やイベント使用の為、技適未取得機器を用いた実験等の特例制度(180日)を利用させて頂きました。
まとめ
・ドキドキの視える化・聴こえる化を共有し、ドキドキの変化に応じてAI解析しメッセージをLINEやメールで送ってくれるドキドキ探知機のプロトタイプが形になりました。
・心拍という生体情報をミリ波レーダで非接触に読み取り、SORACOM Fluxを使ってローコードで生成AIと連携させることができその変化をLEDや振動モータ、メッセージ通知として返すフィジカルAIのプロトタイプが形になりました。自分の頭の中のアイデアが短期間で具現化された非常に満足しております!
余談
・振動モータを購入してマイコンに接続し、5V 電源を別途用意すれば、もっと小型に作ることもできます。今回は、こっそりドキドキを測れないようにするため、あえて大きめのサイズにしました。必ず一言声をかけて測る、という“仕組み”にしたかったためです。
・イベントなどで飛行機で東京に行くときドキドキ探知機を空港のセキュリティゲート通過しなければならないのですが、(金属探知機みたいなものを飛行機で持っていく人はいないと思うので、)空港でこれは何ですか?って声をかけられないか、ある意味ドキドキしました。ちなみに問題なくセキュリティゲートは通過できました。
関連発表
展示
・SORACOM Discovery 2025プロトタイピングコーナー
Lightning Talk (LT)
・SORACOM UG 四国 x kintone Café 高知 Vol.2
・えれくら! ~電気電子工作系製作・交流会~#28
・初心者・初登壇Welcome!LINEを使ったLT大会 #14
note記事
・SORACOM Discovery 2025に参加しました!






