メインページに戻る

目次

このページのゴール

「M5StickC Plus2とPC(ブラウザ)間で、クラウドを介してセンサーデータを送ったり、遠隔でM5StickCを操作できるようにする。また、ネット接続がなくても動くESP-NOWやBluetoothを使えるようにする。」

今日のねらい

ここまでで「HTTP → WebSocket」までを体験しました。次はクラウドでの多対多配信(MQTT)と、インターネット不要の近距離連携(ESP-NOW / Bluetooth)を触って分かるレベルまで進めます

MQTTとは?

IoT(Internet of Things)でよく使われる通信方式のひとつで、正式には Message Queuing Telemetry Transport(メッセージ・キューイング・テレメトリー・トランスポート)と呼ばれます。
もともとは「センサのような小型デバイスでも省電力で通信できるように」設計された軽量プロトコルです。

特徴

  • 軽い:通信量が少なく、Wi-Fiが不安定な環境でも動作しやすい
  • 柔軟:1対1だけでなく、1対多・多対多の通信ができる
  • 仕組み:すべてのメッセージは「ブローカ」と呼ばれるサーバーを介してやり取りされる

0. なぜ MQTT?

様々な通信のやり方がありますが、それぞれ得意なことが違います。

HTTP

Webの標準的な通信方式。
1リクエスト ↔ 1レスポンスのやり取りで、HTML・JSON・画像などの「資源(リソース)」を取得します。

  • 役割
    • クライアント(ブラウザなど)がサーバーにリクエストを送り、サーバーがレスポンスを返す。
  • 得意なこと
    • 単発のデータ取得や設定変更(例:ウェブページの読み込み、フォーム送信)。

WebSocket

最初だけHTTPで「握手」し、その後は常時接続の双方向通信を行う方式。

  • 役割
    • クライアントとサーバーが1本の接続を開いたまま、お互いに自由にデータを送受信できる。
  • 得意なこと
    • リアルタイム通信(例:チャット、センサーデータのストリーミング、オンラインゲーム)。
      ※複数端末を直接つなぐには工夫が必要(サーバーで中継するなど)。

MQTT

IoT向けの軽量メッセージングプロトコル。
TCP(またはWebSocket)の上で動作し、「ブローカ」と呼ばれるサーバーが通信を中継します。

  • 役割
    • 端末(クライアント)は「トピック」単位で送信(publish)や受信(subscribe)を行う。
  • 得意なこと
    • 多対多通信・省電力通信に優れ、クラウド経由で離れた場所のデバイスともやり取りできる。
      例:センサー群 → MQTTブローカ → モニタリングアプリ。
プロトコル通信方向接続の仕組み得意なこと主な用途
HTTP1回限り要求→応答単発通信Webサイト, API
WebSocket双方向常時接続リアルタイム通信チャット, センサー
MQTT多対多ブローカ中継IoT通信センサー, スマート家電

使い分けの例

  • 接続している全員に「画面を赤に変えろ」と一斉に指示を送るMQTTが便利です。
  • 特定の1台のM5StickCとブラウザを常時接続して、データをやり取りするWebSocketが向いています。
  • M5StickCの初期設定を書き換えるだけHTTPで十分です。

MQTTの用語だけ覚える

  • Broker(ブローカ)
    • データの「ハブ(中継地点)」となるサーバーです。例えるなら、YouTubeのサーバーのようなもの。みんながここに動画をアップロードし、みんながここから動画を視聴します。
    • 今回は無料で使える「test.mosquitto.org」を使います。
  • Topic(トピック)
    • データの「宛先」や「種類」を示すものです。メールの件名みたいなものだと思ってください。
    • 例:「m5class/roomA/tsuchida/acc」のように階層的にすることで、どのクラスの誰の何の情報かを分かりやすくできます。
  • Publish(パブリッシュ)
    • データを送信することです。「トピック」を指定してデータを「ブローカ」に送ります。
  • Subscribe(サブスクライブ)
    • データを購読することです。「トピック」を指定して、そのトピックに送られてくるデータを受け取ります。
  • Retain(リテイン)
    • 最後に送られた1件のデータをブローカに「既読メモ」として残しておく機能です。Subscribeした瞬間に、最後に送られたデータを受け取ることができます。

仕組みのイメージ

  1. 各デバイスは「トピック(Topic)」という名前を決めてデータを送る(Publish)。
  2. 受け取りたい側はそのトピックを登録して待つ(Subscribe)。
  3. ブローカが両者を中継して、必要な相手にだけデータを届ける。

たとえば、センサデバイスがTopicB(例:m5lab/roomA/acc)で加速度を送信(Publish)すると、
同じTopicBを購読(Subscribe)しているセンサデバイスやスマートフォンがリアルタイムでその値を受け取れます(下図参照)。


1. MQTT:M5→クラウド→p5.jsで可視化

ここでは、M5StickC Plus2で取得した加速度センサーのデータを、MQTTを使ってクラウドに送り、それをパソコンのブラウザで表示する、という一連の流れを体験します。

M5StickC Plus2: 加速度をPublish(2Hz = 0.5秒おき)

まずはM5StickC Plus2にデータを送るためのプログラムを書き込みます。

Arduino IDEの準備

Arduino IDEを開きます。左のアイコンの上から三つ目(or スケッチ > ライブラリをインクルード > ライブラリを管理) から、PubSubClientというライブラリを検索してインストールします。

M5側

新しいスケッチの作成

ファイル > 新規スケッチ で新しいファイルを開く。

Wi-Fi情報とトピックの変更

以下の部分は必ず自分専用に書き換えてください

YOUR_SSID:Wi-Fi名
YOUR_PASS:Wi-Fiのパスワード
m5class/roomA/alicealiceの部分を自分の名前に変えるなどして、他の人とトピックが重複しないようにします。(例:m5class/roomA/tsuchida)

以下のコードをコピー&ペーストします。

コード(step01_m5_mqtt_pub_acc.ino)
#include <M5StickCPlus2.h>  // M5StickC Plus2 専用のライブラリを読み込む
#include <M5Unified.h>  // M5Stackシリーズを統合的に扱えるライブラリ
#include <WiFi.h>             // ESP32のWi-Fi機能を使うためのライブラリ
#include <PubSubClient.h>     // MQTTの送受信を行うライブラリ

// ===== 自分の Wi-Fi 情報 =====
const char* ssid = "bunjo-design";    // 接続するWi-FiのSSID(要変更)
const char* pass = "bunkajoho-design-kougaku";    // 接続するWi-Fiのパスワード(要変更)

// ===== MQTT ブローカ(無料テスト用)=====
const char* MQTT_HOST = "test.mosquitto.org";  // 公開MQTTサーバ(ブローカ)
const int   MQTT_PORT = 1883;                  // MQTTの標準ポート番号(暗号化なし)

// ===== Topic 設計(データの宛先ラベル)=====
const char* TOPIC_BASE = "m5class/roomA/tsuchida";    // 自分用のベースパス
const char* TOPIC_ACC  = "m5class/roomA/tsuchida/acc"; // 加速度センサー値をPublishする先
const char* TOPIC_CMD  = "m5class/roomA/tsuchida/cmd"; // 制御コマンドをSubscribeする先

WiFiClient      net;              // Wi-Fi経由のTCP通信オブジェクト
PubSubClient    mqtt(net);        // MQTTクライアント(上のTCP通信を使う)

// ============================
// MQTTのコールバック関数(受信したときに呼ばれる処理)
// ============================
void onMqtt(char* topic, byte* payload, unsigned int length){
  String t = String(topic);                       // 受信したトピック名を文字列化
  String s; s.reserve(length);                    // 受信データを格納する文字列を確保
  for(unsigned int i=0;i<length;i++) s += (char)payload[i]; // 受信バイト列を文字列に変換

  if(t == TOPIC_CMD){                             // 自分宛ての「cmd」トピックだけ処理する
    // もし「"fill":"red"」が含まれていたら画面を赤で塗りつぶす
    if(s.indexOf("\"fill\":\"red\"")   >= 0) M5.Display.fillScreen(TFT_RED);
    // もし「"fill":"black"」が含まれていたら画面を黒で塗りつぶす
    if(s.indexOf("\"fill\":\"black\"") >= 0) M5.Display.fillScreen(TFT_BLACK);
  }
}

// ============================
// MQTTに接続しているか確認し、切れていたら再接続する関数
// ============================
void ensureMqtt(){
  while(!mqtt.connected()){                       // もし未接続なら繰り返し試す
    String cid = String("m5-") + String((uint32_t)esp_random(), HEX); // ランダムなクライアントIDを生成
    if(mqtt.connect(cid.c_str())){                // 接続成功したら
      mqtt.subscribe(TOPIC_CMD);                  // 制御用トピックを購読する
    }else{
      delay(1000);                                // 失敗したら1秒待って再試行
    }
  }
}

// ============================
// 起動時に1回だけ実行される初期化処理
// ============================
void setup(){
  auto cfg = M5.config(); M5.begin(cfg);          // M5StickC Plus2全体の初期化
  M5.Display.setRotation(1);                      // 画面の向きを横向きに設定
  M5.Display.setTextSize(2);                      // 文字サイズを2倍に設定
  M5.Display.fillScreen(TFT_BLACK);               // 画面を黒で塗りつぶす
  M5.Display.setCursor(8,8);                      // 文字表示位置を設定
  M5.Display.println("MQTT demo boot...");        // 起動メッセージを表示

  WiFi.begin(ssid, pass);                         // Wi-Fi接続開始
  while(WiFi.status() != WL_CONNECTED){           // 接続完了まで待機
    delay(300);                                   // 300msごとに
    M5.Display.print(".");                        // 画面に「.」を表示して進行中を示す
  }

  M5.Display.fillScreen(TFT_BLACK);               // 画面をクリア
  M5.Display.setCursor(8,8);
  M5.Display.printf("IP: %s\n", WiFi.localIP().toString().c_str()); // 自分のIPを表示
  M5.Display.printf("step1");                     // デバッグ用表示

  mqtt.setServer(MQTT_HOST, MQTT_PORT);           // 接続先のMQTTブローカを設定
  mqtt.setCallback(onMqtt);                       // 受信時に呼ぶ関数を登録
}

// ============================
// メインループ(ずっと繰り返される処理)
// ============================
void loop(){
  M5.update();                                    // ボタンなどの状態を更新
  ensureMqtt();                                   // MQTTが切れていたら再接続
  mqtt.loop();                                    // 受信処理を実行

  static uint32_t t0 = 0;                         // 前回送信した時刻を記録する変数
  uint32_t now = millis();                        // 起動からの経過時間をmsで取得
  if(now - t0 >= 500){                            // 500msごと(=2Hz)に送信
    t0 = now;                                     // 今回の送信時刻を記録
    float ax, ay, az; M5.Imu.getAccel(&ax,&ay,&az);// 加速度センサーの値を取得
    char buf[120];                                // 送信用の文字バッファを用意
    snprintf(buf, sizeof(buf),
      "{\"acc\":[%.3f,%.3f,%.3f]}", ax, ay, az);  // JSON形式に変換 {"acc":[x,y,z]}
    mqtt.publish(TOPIC_ACC, buf, false);          // MQTTでトピックに送信(retain=false)
  }

  delay(1);                                       // CPUの負荷を下げるための小休止
}

コードの解説

全体像(イベント駆動の3本柱)

1) Wi-Fi接続 → 2) MQTT接続&購読 → 3) 一定間隔でPublish
途中で切れたら ensureMqtt() が再接続を担当、mqtt.loop() が受信イベントを回す。

もちろんです。 提供された資料の「コードの解説」は概要(何をしているか)に焦点を当てていますが、ここでは**「なぜそう書くのか」「各行がどう連携しているのか」**という視点で、より丁寧に解説します。

コードを機能のカタマリごとに見ていきましょう。

1. 準備:ライブラリとグローバル変数 (1〜23行目あたり)

プログラムが始まる前に、必要な「道具」と「設定」を準備するエリアです。

// ===== 自分の Wi-Fi 情報 =====
const char* ssid = "...";
const char* pass = "...";

// ===== MQTT ブローカ(無料テスト用)=====
const char* MQTT_HOST = "test.mosquitto.org";
const int   MQTT_PORT = 1883;

// ===== Topic 設計(データの宛先ラベル)=====
const char* TOPIC_BASE = "m5class/roomA/tsuchida";
const char* TOPIC_ACC  = "m5class/roomA/tsuchida/acc"; // 送信(Publish)用
const char* TOPIC_CMD  = "m5class/roomA/tsuchida/cmd"; // 受信(Subscribe)用
  • const char* は「書き換え不可能な文字列」を意味します。設定値なので、間違ってプログラム中で上書きしないように const をつけています。
  • トピックを acc (加速度) と cmd (コマンド) に分けているのがポイントです。データの流れを明確に分離する(データは acc に送り、操作命令は cmd で受け取る)ことで、管理しやすくなります。
WiFiClient   net;        // Wi-Fi(TCP)通信を行うためのオブジェクト
PubSubClient mqtt(net);  // MQTT通信を行うためのオブジェクト
  • PubSubClient は、WiFiClient を使って動作します。
  • これは「MQTTというルール(PubSubClient)は、インターネット(WiFiClient)という土台の上で動きますよ」という宣言です。mqtt(net) のように net を渡すことで、2つを関連付けています。

2. 起動処理:setup() (77〜100行目あたり)

M5StickCの電源が入ったときに一度だけ実行される処理です。Wi-FiとMQTTの初期設定を行います。

void setup(){
  // M5StickCの初期化(画面、センサーなど)
  (略)

  // Wi-Fi接続処理
  (略)

  // Wi-Fi接続完了後の処理
  (略)

  // --- ここからMQTTの初期設定 ---
  
  // 1. 接続先サーバ(ブローカ)を指定
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  
  // 2. メッセージを受信したときに呼び出す関数を「予約」
  mqtt.setCallback(onMqtt);
}
  • mqtt.setServer(...): PubSubClient ライブラリに「これから接続しにいくサーバーは test.mosquitto.org1883 番ポートですよ」と伝えています。
  • mqtt.setCallback(onMqtt): (重要ポイント)
    • これは「もし(購読中のトピックに)何かしらのメッセージが届いたら、onMqtt という名前の関数を呼び出してください」という「予約」です。
    • この時点ではまだ onMqtt 関数は実行されません。あくまで「予約」です。onMqtt 関数の本体は、この setup (28〜41行目) に定義されています。

3. メインループ:loop() (104〜126行目あたり)

setup() が終わった後、無限に繰り返されるメインの処理です。データの送信と、接続の維持を行います。

void loop(){
  M5.update();  // M5のボタン状態などを更新(必須)

  // (A) MQTTが切れていたら再接続する
  ensureMqtt(); 
  
  // (B) MQTTの受信処理と接続維持
  mqtt.loop();  

  // (C) 500ミリ秒ごとにセンサーデータを送信するタイマー処理
  static uint32_t t0 = 0; // 前回の送信時刻 (staticで値を保持)
  uint32_t now = millis();  // 現在の時刻
  
  if(now - t0 >= 500){ // 現在時刻 - 前回時刻 >= 500ms なら
    t0 = now;          // 送信時刻を「今」に更新
    
    float ax, ay, az; M5.Imu.getAccel(&ax,&ay,&az); // 加速度取得
    
    char buf[120]; // 送信データ(JSON)を入れるための「文字の箱」
    // 箱(buf)に、指定した形式で文字列を作成する
    snprintf(buf, sizeof(buf),
      "{\"acc\":[%.3f,%.3f,%.3f]}", ax, ay, az);
    
    // (D) データをMQTTブローカに送信(出版)
    mqtt.publish(TOPIC_ACC, buf, false); 
  }

  delay(1); // CPUを少し休ませる
}
  • (A) ensureMqtt():
    • これは独自に作った関数 (45〜57行目) です。
    • 中身は「もし mqtt.connected() (接続状態) が false だったら、接続を試みる」という処理です。(詳細は後述)
  • (B) mqtt.loop(): (重要ポイント)
    • この命令が、MQTT通信の「心臓」です。
    • これを呼び出すたびに、PubSubClient ライブラリは以下の2つの仕事を行います。
      1. 受信チェック: ブローカから何かメッセージが届いていないか確認する。もし届いていれば、setup で「予約」した onMqtt 関数をここで呼び出す
      2. 接続維持: ブローカに「私はまだ生きていますよ」という信号 (KeepAlive) を自動で送り、接続が切れないようにする。
    • 注意: mqtt.loop() を長期間呼び出さない (例えば delay(5000) などで処理を止める) と、受信もできず、接続も切れてしまいます。
  • (C) static uint32_t ... のタイマー処理:
    • delay(500) などを使わないのは、mqtt.loop() を止めないためです。(ブロッキング/ノンブロッキング処理を習ったかと思います)
    • static をつけると、t0 変数が loop を繰り返しても値がリセットされず、前回の値を保持し続けます。これにより「前回送信した時刻」を覚えておくことができます。
  • (D) mqtt.publish(TOPIC_ACC, buf, false):
    • これが「publish(出版)」です。
    • TOPIC_ACC (宛先) に buf (データ) を送ります。
    • 最後の false は「Retain (リテイン) しない」という意味です。
      • Retain = false (今回の場合)
        • 用途: 加速度、温度、チャットのメッセージなど、連続的に送られるデータ(ストリーム)。
        • 意味: 「今、この瞬間の値」だけを送る。後から接続した人は、次のデータから参加する。
      • Retain = true
        • 用途: デバイスの状態 ( ONLINE / OFFLINE )、部屋の電気 ( ON / OFF )、設定値など、「現在の状態」が重要なデータ
        • 意味: 「これが最新の状態です」と掲示板に貼っておく。後から接続した人も、まずその最新の状態を知ることができる。

4. 裏方の処理 (1) :ensureMqtt() (45〜57行目)

loop() から呼ばれる、接続を確立・維持するためのカスタム関数です。

void ensureMqtt(){
  while(!mqtt.connected()){ // (E) もし接続されていなければ...
    // (F) ランダムなクライアントIDを生成
    String cid = String("m5-") + String((uint32_t)esp_random(), HEX); 
    
    if(mqtt.connect(cid.c_str())){ // (G) ブローカに接続を試みる
      // (H) 接続に成功したら、コマンド用トピックを「購読」
      mqtt.subscribe(TOPIC_CMD); 
    }else{
      delay(1000); // 失敗したら1秒待ってリトライ
    }
  }
}
  • (E) while(!mqtt.connected()):
    • loop() の中でこれが呼ばれると、「接続が確立するまで、この while ループから抜け出さない」という動作をします。
  • (F) ランダムなクライアントID:
    • MQTTブローカは、接続してくるクライアントを「クライアントID」で区別します。このIDが他の人と重複すると、片方が切断されてしまいます。
    • esp_random() でランダムなIDを生成することで、重複を防いでいます。
  • (G) mqtt.connect(...): 実際にブローカに接続を試みます。
  • (H) mqtt.subscribe(TOPIC_CMD): (重要ポイント)
    • 接続が成功したら、すぐに「私は TOPIC_CMD というトピックのメッセージを受け取りたいです」とブローカに「購読」の申し込みをします。
    • これを忘れると、TOPIC_CMD にいくらデータが送られてきても、M5StickCには届きません。

5. 裏方の処理 (2) :onMqtt() (28〜41行目)

mqtt.loop() によってメッセージの受信が検知されたときに、自動で呼び出される関数です。

void onMqtt(char* topic, byte* payload, unsigned int length){
  String t = String(topic); // 1. トピック名を文字列に
  
  // 2. 受信データ(byteの配列)を文字列に
  String s; s.reserve(length);
  for(unsigned int i=0;i<length;i++) s += (char)payload[i];

  // 3. どのトピック宛のメッセージかを確認
  if(t == TOPIC_CMD){ 
    // 4. メッセージの内容(s)をチェック
    if(s.indexOf("\"fill\":\"red\"")   >= 0) M5.Display.fillScreen(TFT_RED);
    if(s.indexOf("\"fill\":\"black\"") >= 0) M5.Display.fillScreen(TFT_BLACK);
  }
}
  • この関数は「予約」されていたものなので、引数 ( topic, payload, length ) はライブラリから自動で渡されます。
  • 1 & 2: payload は生のバイトデータ (byte*) で届くため、for ループで1文字ずつ String に変換しています。
  • 3: 自分が購読しているトピックが複数ある可能性も考え、届いたメッセージが TOPIC_CMD だった場合のみ処理を進めます。
  • 4: s.indexOf(...) は、文字列 s の中に指定した文字列 (例: "fill":"red") が含まれているかを探す命令です。含まれていれば、その場所を返し (0以上)、含まれていなければ -1 を返します。
  • これにより、PC側から {"fill":"red"} というJSONを TOPIC_CMD 宛にPublishするだけで、M5StickCの画面を遠隔操作できます。

実行

  • M5StickC Plus2をPCに接続し、スケッチ > マイコンボードに書き込む を実行します。
  • 書き込みが完了すると、M5StickC Plus2の画面にIPアドレスが表示され、Wi-FiとMQTTブローカへの接続が成功したことを示します。

何が「クラウド」になるのか?

MQTTの世界観だと、M5はPublish(送信)、p5.jsはSubscribe(受信)をします。
その間を取り持つ別途サーバがMQTTブローカ
で、場所(=運用形態)は3パターンあります。

  1. 公開テスト用ブローカ(例:test.mosquitto.org など)
  • 早い・準備不要なので授業の体験用に向いてる。
  • ただし「丸見え&不安定」(誰でも使える/たまに落ちる/履歴保存もしない)。本番・秘匿データ利用不可です。あくまでテストです。
  1. マネージド・クラウド(業者が運用:AWS IoT Core / HiveMQ Cloud / EMQX Cloud / Adafruit IO 等)
  • 数分で発行されるホスト名・認証情報・証明書で接続。
  • ダッシュボードやアクセス制御も揃う。小規模なら無料枠があると思います。
  1. 自前で建てる(研究室VPSや学内サーバ、あるいは自分のPC上)
  • 例:Mosquitto/EMQXをDockerで起動し、1883(MQTT)/8883(TLS)WebSocket(8083/443)を開ける。
  • 完全に自分で管理(柔軟だが、証明書やファイアウォールの設定が必要)。
    • (サーバー周りはLe先生が詳しいので、やってみたい人はLe先生に。私は素人に毛が生えた程度です。)


2. p5.js:MQTT over WebSocket を購読して描画

次に、M5StickC Plus2から送られてきたデータを受信し、ブラウザ上でリアルタイムに表示するプログラムを作成します。

PC側

  1. フォルダとファイルの作成
    • PCにstep02_p5_mqtt_viewという名前の新しいフォルダを作ります。
    • その中にindex.htmlsketch.jsという2つのファイルを作成します。
  2. Live Serverの準備
    • Visual Studio Codeなどのエディタを使い、step02_p5_mqtt_viewフォルダを開きます。
    • 拡張機能のLive Serverをインストールしておくと便利です。インストール後、画面右下のGo Liveボタンをクリックすると、ブラウザでローカルサーバーが起動します。

index.htmlの記述

以下のコードをindex.htmlにコピー&ペーストします。このファイルは、p5.jsとブラウザ用のMQTTクライアントライブラリ(sketch.js)を読み込む役割をします。

コード(index.html)
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>p5 x M5 HTTP</title>
    <!-- p5.js 本体(CDNから読み込み) -->
    <script src="https://cdn.jsdelivr.net/npm/p5@1.10.0/lib/p5.min.js"></script>
      <!-- ブラウザ用 MQTT クライアント(mqtt.js) -->
    <script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
  </head>
  <body>
    <main></main>
    <!-- 自分のスケッチを最後に読み込む -->
    <script src="sketch.js"></script>
  </body>
</html>

sketch.js(自分の TOPIC に変更)

  • 以下のコードをsketch_mqtt.jsにコピー&ペーストします。
  • step01_m5_mqtt_pub_acc.inoM5StickC Plus2)のトピックに合わせて、TOPIC_ACCの部分を必ず書き換えてください。
コード(sketch.js)
// ==== 自分のトピックに必ず合わせる! ====
// M5 が publish しているトピック名。ここを自分専用のものに変更する。
const TOPIC_ACC = "m5class/roomA/tsuchida/acc"; // 加速度センサー値を受信するトピック

let client;                 // MQTT クライアント(ブラウザ用 mqtt.js で生成)
let acc = [0,0,0];          // 最新の加速度データを格納する配列 [ax, ay, az]
let lastOkAt = 0;           // 最後に正しくデータを受信した時刻(ms)

function setup(){
  createCanvas(600, 360);   // p5.js の描画キャンバスを生成(幅600px, 高さ360px)

  // test.mosquitto.org の WebSocket(S) エンドポイントに接続する
  // 学内や自宅で 8081 ポートが塞がれている場合は、別のクラウドブローカやポートを利用する必要あり
  client = mqtt.connect('wss://test.mosquitto.org:8081');

  // 接続成功時に呼ばれる処理
  client.on('connect', () => {
    console.log('connected');           // デバッグ用にログを出す
    client.subscribe(TOPIC_ACC);        // 指定したセンサートピックを購読開始
  });

  // メッセージを受信したときに呼ばれる処理
  client.on('message', (topic, message) => {
    if(topic !== TOPIC_ACC) return;     // 関係ないトピックは無視

    try{
      const j = JSON.parse(message.toString()); // 受信メッセージをJSONとして解釈
      if(Array.isArray(j.acc)) acc = j.acc;     // JSONに "acc" 配列があれば上書き
      lastOkAt = performance.now();             // データを受信した時刻を記録
    }catch(e){ /* JSON変換エラーは無視する */ }
  });
}

function draw(){
  background(245);                      // 背景を薄いグレーで塗る
  noStroke(); fill(30);                 // 線なし・濃いグレーで塗りつぶす

  // 画面中央を基準に、加速度のX/Y値で円を移動させる
  const cx = width/2  + acc[0]*60;      // acc[0] = X方向の加速度
  const cy = height/2 + acc[1]*60;      // acc[1] = Y方向の加速度
  circle(cx, cy, 24);                   // 直径24pxの円を描画

  // デバッグ情報の表示
  fill(0); textSize(14);                // 黒文字・文字サイズ14
  text(`acc: ${acc.map(v=>v.toFixed(3)).join(', ')}`, 12, 22); // 加速度値(小数3桁)
  text(`age: ${(performance.now()-lastOkAt|0)} ms`, 12, 42);   // 最後の受信からの経過時間
}

コードの解説

1. グローバル変数 (1〜7行目)

setup()draw() の外側で定義する変数は、プログラム全体で共有されます。

// ==== 自分のトピックに必ず合わせる! ====
const TOPIC_ACC = "m5class/roomA/tsuchida/acc"; // 加速度センサー値を受信するトピック

let client;                 // MQTT クライアント(接続オブジェクトが入る「箱」)
let acc = [0,0,0];          // 最新の加速度データを格納する配列 [ax, ay, az]
let lastOkAt = 0;           // 最後に正しくデータを受信した時刻(ms)
  • TOPIC_ACC:
    • M5StickCがPublish (送信) しているトピック名と、一字一句同じである必要があります。ここが違うと、データが送られてきても「自分宛ではない」と判断して無視してしまいます。
  • client:
    • mqtt.connect() で生成される「MQTTブローカとの接続そのもの」を保持する変数です。
  • acc:
    • データバッファです。MQTTは不定期にデータを受信しますが、p5.jsの draw()定期的(毎秒60回)に実行されます。draw() は「最後に受信したaccの値」を常に見にいきます。[0,0,0] で初期化することで、最初のデータが届く前でもエラーなく円を描画できます。
  • lastOkAt:
    • 最後にデータを受信してからの経過時間を計算するための、デバッグ用のタイムスタンプです。

2. setup() 関数 (9〜28行目あたり)

p5.jsが起動したときに一度だけ呼ばれる初期設定の関数です。

function setup(){
  createCanvas(600, 360);   // p5.js の描画キャンバスを生成

  // (A) ブローカへ接続
  client = mqtt.connect('wss://test.mosquitto.org:8081');

  // (B) 接続が成功した「ら」呼び出す処理を予約
  client.on('connect', () => {
    console.log('connected');           // 接続成功をコンソールに表示
    client.subscribe(TOPIC_ACC);        // (C) センサートピックの購読を開始
  });

  // (D) メッセージを受信した「ら」呼び出す処理を予約
  client.on('message', (topic, message) => {
    if(topic !== TOPIC_ACC) return;     // (E) 関係ないトピックは無視

    try{
      // (F) 受信データをJSONとして解析
      const j = JSON.parse(message.toString()); 
      // (G) "acc" という名前の配列が入っていれば、グローバル変数を更新
      if(Array.isArray(j.acc)) acc = j.acc;     
      lastOkAt = performance.now(); // (H) 最後に受信した時刻を更新
    }catch(e){ /* JSON変換エラーは無視する */ }
  });
}
  • (A) mqtt.connect(...):
    • mqtt.js ライブラリの機能を使って、MQTTブローカに接続を開始します。
    • なぜ wss://:
      • ブラウザで動くJavaScriptは、セキュリティ上の理由から、暗号化されたWebSocket (wss://) 接続を要求することが多いです。M5StickCが使った mqtt:// (ポート 1883) ではなく、WebSocket用の wss://test.mosquitto.org:8081 を使います。
  • (B) client.on('connect', ...):
    • これはイベントリスナー(コールバック)です。「connect (接続成功)」というイベントが発生したら、() => { ... } の中の処理を実行してね、という「予約」です。
    • 接続は一瞬で終わるわけではない(非同期)ため、この書き方が必要です。
    • (C) client.subscribe(TOPIC_ACC):
      • ブローカに「私は TOPIC_ACC というトピックに興味があります。データが来たら送ってください」と購読の申し込みをします。
      • connect イベントの中で実行するのが重要です。接続が確立する前に購読しようとしても失敗するからです。
  • (D) client.on('message', ...):
    • これもイベントリスナーです。「message (メッセージ受信)」というイベントが発生したら、 (topic, message) => { ... } の処理を実行してね、という「予約」です。
    • M5StickCからデータがPublishされるたびに、この部分が呼び出されます。
  • (E) if(topic !== TOPIC_ACC) return;:
    • ガード節(門番)です。もし他のトピックも購読していた場合、関係ないトピックのデータが来たら、ここで処理を終了 (return) します。
  • (F) JSON.parse(message.toString()):
    • message は、M5が送った {"acc":[0.1,0.2,0.3]} という文字列データ (正確には byte 配列) です。
    • .toString() でまず文字列に変換し、JSON.parse() でJavaScriptが扱えるオブジェクト(連想配列)に変換します。
    • try...catch で囲っているのは、通信が不安定なときに {"acc":[ のように壊れたJSONが届くと JSON.parse() がエラーを起こしてプログラム全体が停止してしまうのを防ぐためです。
  • (G) if(Array.isArray(j.acc)) acc = j.acc;:
    • これも安全対策です。j の中に acc というキーがあり、その中身がちゃんと Array (配列) であった場合のみ、グローバル変数の acc を更新します。
  • (H) lastOkAt = performance.now();:
    • 「今、正常なデータを確かに受信した」という時刻を記録します。draw() 側でこれを使います。

3. draw() 関数 (30〜41行目)

setup() の後に繰り返し実行される、描画専用の関数です。

function draw(){
  background(245);                      // (I) 背景を塗りつぶしてリセット
  noStroke(); fill(30);                 // 線なし・濃いグレー

  // (J) グローバル変数 `acc` の値を使って円の位置を計算
  const cx = width/2  + acc[0]*60;      // acc[0] = X方向の加速度
  const cy = height/2 + acc[1]*60;      // acc[1] = Y方向の加速度
  circle(cx, cy, 24);                   // 円を描画

  // (K) デバッグ情報の表示
  fill(0); textSize(14);
  text(`acc: ${acc.map(v=>v.toFixed(3)).join(', ')}`, 12, 22);
  text(`age: ${(performance.now()-lastOkAt|0)} ms`, 12, 42); 
}
  • (I) background(245):
    • これがないと、前に描画した円がすべて残像として残ってしまいます。
  • (J) const cx = ...:
    • draw() 関数は、MQTTの受信タイミングを一切気にしません
      • draw() は「今、acc 変数に入っている値」をただ読み取って円を描くだけです。
      • client.on('message') (受信処理) が裏側で acc の値をこっそり更新し、draw() (描画処理) はそれを参照する、という役割分担ができています。
  • (K) text(age: ...):
    • performance.now() (現在の時刻) から lastOkAt (最後に受信した時刻) を引いています。
    • もしM5StickCの電源が切れたり、Wi-Fiが切れたりすると、client.on('message') が呼ばれなくなり、lastOkAt が更新されなくなります。
    • その結果、この age の数値がどんどん増えていくため、「あ、データが来てないな」と視覚的に判断できます。

動作確認

  1. M5StickC Plus2の電源が入っていることを確認します。
  2. Live Serverを起動してブラウザでindex.htmlを開きます。
  3. M5StickC Plus2を傾けてみてください。 ブラウザの画面上の円が、傾きに合わせて動けば成功です。

現在の構成

  • M5 (ESP32/M5StickC Plus2)
    → MQTT クライアントとして test.mosquitto.org という「公開 MQTT ブローカ」に対してひたすら Publish(送信)しています。
    • 例: m5class/roomA/tsuchida/acc トピックに加速度データを送信。
  • p5.js (ブラウザ側)
    → 同じく test.mosquitto.org のブローカに Subscribe(購読)して、流れてきたメッセージを受け取って可視化しています。

両者は直接つながっているわけではなく、MQTT ブローカを介した「一方通行」 です。
イメージ:
[M5StickC Plus2] –Publish–> [ test.mosquitto.org (Broker) ] –Subscribe–> [p5.js ブラウザ]

「やり取り(双方向通信)」をしたい場合

双方向にしたければ、p5.js 側からも同じブローカへ Publish する必要があります。
例えば:

  • p5.js → m5class/roomA/alice/cmd"fill":"red" を送信
  • M5StickC Plus2 → TOPIC_CMD を Subscribe して受け取り、画面を赤にする

このように 「acc」トピックでセンサーデータを送信し、「cmd」トピックで制御コマンドを受信する、という形にすれば完全な双方向になります。


3. p5.js→MQTT→M5:遠隔コマンド(赤/黒)

ということで今度は逆に、ブラウザからM5StickC Plus2を操作してみましょう。(M5側にはすでにsubscribeを仕込み済み)

PC側

sketch_mqtt.jsの追記

  • sketch_mqtt.jsファイルの、draw()関数の下あたりに以下のコードを追加します。
  • ここでも、トピック(TOPIC_CMD)を自分専用のものに合わせるのを忘れないでください。
function setup(){
...
}

function draw(){
...
}

// ==== 以下を追加! ====

// クリック送信用のトグル
let isRed = false;          // 次に送る色(false=black, true=red)
// 自分の CMD トピックに合わせる
const TOPIC_CMD = "m5class/roomA/tsuchida/cmd";

// クリックで赤↔黒を交互に Publish
function mousePressed(){
  if(!client || client.disconnected) return;
  isRed = !isRed;                           // トグル
  const msg = isRed ? {"fill":"red"} : {"fill":"black"}; // 送るJSON
  client.publish(TOPIC_CMD, JSON.stringify(msg)); // retain=false でOK
}

コードの解説

  • mousePressed()
    • ブラウザ上でマウスをクリックするとこの関数が実行されます。
  • client.publish()
    • {"fill":"red"} または {"fill":"black"} というJSON文字列をM5StickC Plus2が購読しているトピック(TOPIC_CMD)へ送信します。
    • M5StickC Plus2側のコードでは、すでにonMqtt()関数内でこのコマンドを受け取る準備ができています。onMqtt()関数は、TOPIC_CMDにデータが届くと、画面の色を変える処理を実行します。(下に該当部分を再掲)

M5側

onMqtt() 関数だけ(該当部分)

すでに書き込まれているM5のコード(step01_m5_mqtt_pub_acc.ino)の 受信コールバック 部分を再掲しています。
p5.js が TOPIC_CMD に送ってくる {"fill":"red"} / {"fill":"black"} を受け取り、画面色を変えます。
※ すでに仕込み済みなのでここでは書き換え不要

// 受信時に呼ばれるコールバック(PubSubClient用)
// 期待JSON: {"fill":"red"} or {"fill":"black"}
void onMqtt(char* topic, byte* payload, unsigned int length){
  // トピック一致チェック
  if (String(topic) != TOPIC_CMD) return;

  // 受信ペイロードを String に変換
  String s; s.reserve(length);
  for (unsigned int i = 0; i < length; i++) s += (char)payload[i];

  // とりあえず簡易パース(高速&軽量)
  if (s.indexOf("\"fill\":\"red\"") >= 0) {
    M5.Display.fillScreen(TFT_RED);
  } else if (s.indexOf("\"fill\":\"black\"") >= 0) {
    M5.Display.fillScreen(TFT_BLACK);
  }

  // もし厳密にJSONパースしたい場合は ArduinoJson 等を利用するとよいです。
  // 例:
  // StaticJsonDocument<128> doc;
  // auto err = deserializeJson(doc, s);
  // if (!err && doc.containsKey("fill")) { ... }
}

動作確認

  • M5
    • TOPIC_ACC に加速度を Publish、TOPIC_CMD を Subscribe(↑の onMqtt() を設定)。
  • p5
    • TOPIC_ACC を Subscribe(可視化) + クリックで TOPIC_CMD に Publish(色指示)。
  • ブラウザの画面をクリックするたびに、M5StickC Plus2の画面が赤と黒で交互に切り替われば成功です!

【整理】

[M5StickC Plus2]
Publish → m5class/roomA/tsuchida/acc
Subscribe ← m5class/roomA/tsuchida/cmd

[test.mosquitto.org (ブローカ)]

[p5.js ブラウザ]
Subscribe ← m5class/roomA/tsuchida/acc
Publish → m5class/roomA/tsuchida/cmd


4. CSV ログ保存(Node.js の活用)

(※第5回のNode.jsと内容一部被っています)

p5.jsでセンサーの動きを確認しつつ、裏ではそのデータを自動でファイルに保存できるようにします。Node.jsを使えば、データをまとめて記録できます。

p5.jsとNode.jsの違い

p5.js

  • どこで動く?
    → ブラウザ(Chrome, Edge, Safari など)
  • どうやって動く?
    → HTMLに <script src="p5.min.js"> を読み込むだけでOK
  • 得意なこと
    • 図形や文字をキャンバスに描く
    • キーボード・マウス・タッチ操作に反応する
    • 音・映像・センサー入力をリアルタイムに可視化する
  • 授業の位置づけ
    • 見える部分(フロントエンド)
    • M5から飛んできたデータを画面に表示したり、クリックしてコマンドを送ったりする「インターフェース」

Node.js

  • どこで動く?
    → サーバーやPCの中(ブラウザの外)
  • どうやって動く?
    → ターミナルで node ファイル名.js を実行する
  • 得意なこと
    • ファイルの読み書き(CSV保存など)
    • ネットワーク接続(MQTT, HTTP, WebSocket)
    • バックグラウンド処理(記録、サーバーAPI)
  • 授業の位置づけ
    • 裏方(バックエンド)
    • 全員分のデータを自動でロギングしたり、後から解析できるように保存する「記録係」

PC側

Node.jsの準備

参考:https://qiita.com/sefoo0104/items/0653c935ea4a4db9dc2b

  • 公式サイトから LTS(推奨版)を入れる。
    • Macの場合は以下のようなコード
      • # nvmをダウンロードしてインストールする:
        curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
      • # シェルを再起動する代わりに実行する
        \. "$HOME/.nvm/nvm.sh"
      • # Node.jsをダウンロードしてインストールする:
        nvm install 22
      • # Node.jsのバージョンを確認する:
        node -v # “v22.19.0″が表示される。
      • # npmのバージョンを確認する:
        npm -v # “10.9.3”が表示される。
    • Windowsの場合はPoweShellを利用ページ下のインストーラー(.msi)を使用した方が良さそうです。

  • インストール後、ターミナル(mac)/PowerShell(Win)を開き直して確認:
node -v   # 例: v20.x.x (数字が出ればOK)
npm -v    # 例: 10.x.x

※「node が見つかりません」→ インストールし直し / ターミナルを閉じて再起動

Windowsの場合はnpmでエラーが出ます。

以下のコマンドをPowerShellで実行してください。

Set-ExecutionPolicy RemoteSigned -Scope CurrentUser

上記のように動くようになるかと思います。

プロジェクトを作る

  • ターミナル(WindowsならPowerShellやコマンドプロンプト、macOSならターミナル)を開きます。
  • 以下のコマンドを順番に実行します。
mkdir step04_node_logger
cd step04_node_logger
npm init -y
npm i mqtt
mkdir logs

npm = Node Package Manager の略。
Node.js 用の「アプリや部品(ライブラリ)」をダウンロード・管理するための道具です。

  • 例:npm i mqtt → MQTTを使うためのライブラリを入れる
  • 例:npm init → プロジェクトの設定ファイルを作る

つまり Node.js のアプリに必要な部品を追加する仕組み、と思えばOKです。

logger.jsの作成

  • VSCodeを使ってmqtt-loggerフォルダ内にlogger.jsというファイルを作成し、以下のコードをコピー&ペーストします。
  • このコードは全員分のデータをまとめて受信できるように、トピックに+というワイルドカードを使っています。
    • ワイルドカードは 「なんでもOK」の代役文字 です。
    • MQTT のトピックで + を使うと、1階層ぶんだけ「何が来てもマッチする」ようになります。
      • m5class/roomA/+/acc
        • m5class/roomA/alice/acc
        • m5class/roomA/bob/acc
        • m5class/roomA/charlie/acc
        • m5class/roomA/tsuchida/acc
コード(logger.js)
// logger.js
// ============================================================
// MQTT → CSV 裏録画ロガー(Node.js超入門版)
// 目的:全員の M5 が publish する {acc:[ax,ay,az]} を
//       PCで日付別CSVに追記保存する。
// ブローカ:test.mosquitto.org(公開テスト用)
//           ※デモ用途のみ。本番や秘匿データはマネージド/自前ブローカで!
// 受信例:topic = m5class/roomA/alice/acc
//         payload = {"id":"m5-xxxx","acc":[0.01,-0.98,0.04]}
// ============================================================

// ----- 必要なライブラリの読み込み -----
const mqtt = require('mqtt');  // MQTT 通信用ライブラリ(npmで導入)
const fs   = require('fs');    // ファイル操作ライブラリ(標準で付属)
const path = require('path');  // パス操作ライブラリ(標準で付属、OS差を吸収)

// ----- 接続設定 -----
const BROKER_URL   = 'mqtt://test.mosquitto.org:1883'; // MQTTサーバー(ブローカ)のURL

// 購読するトピックのパターン
// 「+」はワイルドカードで1階層分をなんでもマッチさせる
// この例では m5class/roomA/誰か/acc に送られてきた全員分を受信できる
const TOPIC_FILTER = 'm5class/roomA/+/acc';

// ログ保存用ディレクトリ(このフォルダにCSVを作る)
const LOG_DIR = path.join(__dirname, 'logs');

// ----- 日付ごとにファイル名を作る関数 -----
// 例:2025年9月21日なら logs/acc-20250921.csv
function todayCsvPath(){
  const d = new Date();                           // 現在時刻を取得
  const y = d.getFullYear();                      // 年(例: 2025)
  const m = String(d.getMonth()+1).padStart(2,'0'); // 月(2桁、例: 09)
  const day = String(d.getDate()).padStart(2,'0');  // 日(2桁、例: 21)
  return path.join(LOG_DIR, `acc-${y}${m}${day}.csv`); // ファイルパスを組み立て
}

// ----- CSVファイルにヘッダを付ける関数 -----
// 初めてファイルを作るとき、または空ファイルのときだけ「iso,topic,ax,ay,az」を書く
function ensureHeader(filePath){
  if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive:true }); // logsフォルダが無ければ作成
  if (!fs.existsSync(filePath) || fs.statSync(filePath).size === 0) {     // ファイルが無い/空なら
    fs.appendFileSync(filePath, 'iso,topic,ax,ay,az\n', 'utf8');          // 1行目にヘッダを書き込み
  }
}

// ----- MQTTサーバーに接続 -----
const client = mqtt.connect(BROKER_URL, {
  // 本番運用なら clientId / username / password も設定すべき
  // clientId: 'logger-' + Math.random().toString(16).slice(2),
  // username: 'ユーザー名',
  // password: 'パスワード',
  // keepalive: 30, // 接続維持のための心拍間隔(秒)
});

// 接続成功時に呼ばれる
client.on('connect', () => {
  console.log('[MQTT] connected:', BROKER_URL);           // 接続成功メッセージ
  client.subscribe(TOPIC_FILTER, (err) => {               // トピックを購読
    if (err) console.error('[MQTT] subscribe error:', err); // エラー時
    else     console.log('[MQTT] subscribed:', TOPIC_FILTER); // 正常時
  });
});

// 接続エラー時
client.on('error', (err) => {
  console.error('[MQTT] error:', err);
});

// ----- メッセージを受信したときの処理 -----
client.on('message', (topic, payloadBuf) => {
  // payloadBuf はバイト列 → 文字列 → JSONオブジェクトへ変換
  let j;
  try {
    j = JSON.parse(payloadBuf.toString()); // JSONとしてパース
  } catch {
    // JSONでなければ無視(例: ゴミデータや他の形式)
    return;
  }

  // 想定しているのは {"acc":[ax,ay,az]} の形式
  if (!Array.isArray(j.acc) || j.acc.length < 3) return; // accが無ければ無視

  const [ax, ay, az] = j.acc;                   // 配列から加速度を取り出し
  const iso = new Date().toISOString();         // 現在時刻をISO8601形式で文字列化
  const line = `${iso},${topic},${ax},${ay},${az}\n`; // CSV形式の1行に整形

  const csvPath = todayCsvPath();               // 今日の日付ファイルを決定
  ensureHeader(csvPath);                        // ヘッダが無ければ追加

  try {
    // ファイルに1行追記
    // appendFileSync は「同期処理」なので確実だが高頻度では重い。
    // 授業で数Hz×数十人なら十分対応可能。
    fs.appendFileSync(csvPath, line, 'utf8');
    // console.log(line.trim()); // デバッグ時だけ有効化して確認
  } catch (e) {
    console.error('[LOG] append error:', e);   // 書き込みエラー
  }
});

// ----- Ctrl+C で終了したときの後始末 -----
process.on('SIGINT', () => {
  console.log('\n[SYS] SIGINT: closing...');   // Ctrl+C が押されたら表示
  client.end(true, () => process.exit(0));     // MQTT接続を終了してプログラム終了
});

コード解説

1. 接続設定とワイルドカード購読

この部分は、どのMQTTブローカに接続し、どのトピックのデータを受信するかを設定しています。

// ----- 接続設定 -----
const BROKER_URL   = 'mqtt://test.mosquitto.org:1883'; // MQTTサーバー(ブローカ)のURL

// 購読するトピックのパターン
// 「+」はワイルドカードで1階層分をなんでもマッチさせる
const TOPIC_FILTER = 'm5class/roomA/+/acc';
  • BROKER_URL:
    • MQTTサーバー(ブローカ)のアドレスです。データを受け渡す中継役です。ここでは公開テスト用のサーバーを使っています。
  • TOPIC_FILTER:
    • データを受信するために「購読」するトピックのパターンです。
      • +(プラス記号)はMQTTのワイルドカードです。この記号がある階層なら、どんな名前でも一致します。
      • 例: m5class/roomA/alice/accm5class/roomA/bob/acc の両方を受信できます。これにより、全員のデータをまとめて集められます。

2. 日付別ファイル名の生成

データがいつのものかを明確にするため、受信した日の日付をファイル名に使います。

// ----- 日付ごとにファイル名を作る関数 -----
// 例:2025年9月21日なら logs/acc-20250921.csv
function todayCsvPath(){
  const d = new Date();                           // 現在時刻を取得
  const y = d.getFullYear();                      // 年(例: 2025)
  const m = String(d.getMonth()+1).padStart(2,'0'); // 月(2桁、例: 09)
  const day = String(d.getDate()).padStart(2,'0');  // 日(2桁、例: 21)
  return path.join(LOG_DIR, `acc-${y}${m}${day}.csv`); // ファイルパスを組み立て
}
  • new Date():
    • プログラムが実行された現在時刻を取得します。
  • d.getMonth()+1: JavaScriptの月は0(1月)から始まるため、+1をして実際の月に直しています。
  • padStart(2,'0'):
    • 月や日が1桁(例: 9)だった場合に、先頭に0を加えて2桁(例: 09)にする処理です。これによりファイル名(acc-YYYYMMDD.csv)が統一され、エクスプローラーなどでファイルが日付順に正しく並びます。

3. CSVヘッダの保証(ensureHeader

CSVファイルを開いた際、データが何を示しているかすぐに分かるように、ファイルの一番上に「見出し」を付けます。

// ----- CSVファイルにヘッダを付ける関数 -----
function ensureHeader(filePath){
  if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive:true }); // logsフォルダが無ければ作成
  if (!fs.existsSync(filePath) || fs.statSync(filePath).size === 0) {     // ファイルが無い/空なら
    fs.appendFileSync(filePath, 'iso,topic,ax,ay,az\n', 'utf8');          // 1行目にヘッダを書き込み
  }
}
  • if (!fs.existsSync(filePath) || fs.statSync(filePath).size === 0):
    • fs.existsSync(filePath): 指定したファイルが存在するかどうかをチェックします。
    • fs.statSync(filePath).size === 0: ファイルが空っぽ(サイズが0バイト)かどうかをチェックします。
    • ファイルが「存在しない」または「空っぽ」の場合に、ヘッダ行を書き込みます。
  • fs.appendFileSync(filePath, 'iso,topic,ax,ay,az\n', 'utf8'): ファイルの末尾に(ヘッダの)1行を追記します。

4. メッセージ受信とデータ処理

MQTTでメッセージを受信した際、データをCSV形式に変換してファイルに書き込むメインの処理です。

// ----- メッセージを受信したときの処理 -----
client.on('message', (topic, payloadBuf) => {
  // ... (JSONパースと検証の省略) ...

  const [ax, ay, az] = j.acc;                   // 配列から加速度を取り出し
  const iso = new Date().toISOString();         // 現在時刻をISO8601形式で文字列化
  const line = `${iso},${topic},${ax},${ay},${az}\n`; // CSV形式の1行に整形

  const csvPath = todayCsvPath();               // 今日の日付ファイルを決定
  ensureHeader(csvPath);                        // ヘッダが無ければ追加

  try {
    fs.appendFileSync(csvPath, line, 'utf8');
  } catch (e) {
    console.error('[LOG] append error:', e);   // 書き込みエラー
  }
});
  • client.on('message', (topic, payloadBuf) => { ... }):
    • MQTTでメッセージを受信するたびに実行される関数です。
    • topic: メッセージが送られてきたトピック名(例: m5class/roomA/alice/acc
    • payloadBuf: メッセージのデータ(ここではJSON形式のバイト列)
  • const [ax, ay, az] = j.acc;:
    • 受信したJSONオブジェクトjの中の加速度データj.acc(配列)を、それぞれの変数に分割代入しています。
  • const line = \${iso},${topic},${ax},${ay},${az}\n`;`:
    • テンプレートリテラル(バッククォート ` で囲まれた文字列)を使い、変数の中身を埋め込みながらCSVの1行を効率よく作成しています。
  • fs.appendFileSync(csvPath, line, 'utf8');:
    • 作成したCSVの1行を、指定されたファイルに同期的に(確実に)追記します。

5. 終了処理

プログラムが停止する際に、通信接続を適切に終了させるための処理です。

// ----- Ctrl+C で終了したときの後始末 -----
process.on('SIGINT', () => {
  console.log('\n[SYS] SIGINT: closing...');   // Ctrl+C が押されたら表示
  client.end(true, () => process.exit(0));     // MQTT接続を終了してプログラム終了
});
  • process.on('SIGINT', ...):
    • ユーザーがCtrl+Cキーを押したとき(プログラム停止のシグナル)を捕捉します。
  • client.end(true, ...):
    • MQTTブローカとの接続をクリーンに(相手に接続終了を通知して)切断します。
  • process.exit(0):
    • 接続が切れた後に、プログラムを正常終了させます。

この処理により、プログラムを強制終了させるのではなく、通信上の作法を守って終了させることができます。

起動する

ターミナル/powershellで以下のコマンドを実行します。

node logger.js
  • 実行すると、MQTT connectedと表示されます。
  • この状態でM5StickC Plus2を傾けると、mqtt-logger/logs/フォルダの中にacc-YYYYMMDD.csvというファイルが自動で作成され、データがどんどん記録されていきます。

停止方法:Ctrl + C

確認する

  • フォルダmqtt-logger/logs/ (cdを使ってディレクトリを移動しておきましょう
  • ファイルacc-YYYYMMDD.csv
  • 中身例
iso,topic,ax,ay,az
2025-09-21T03:12:45.123Z,m5class/roomA/alice/acc,0.02,-0.98,0.07
2025-09-21T03:12:45.623Z,m5class/roomA/bob/acc,-0.01,-0.97,0.12
...
  • Mac
    • tail -f acc-YYYYMMDD.csv をターミナル等で叩けばデータが増えていく様子をリアルタイムで確認できる。
  • Windows
    • Get-Content acc-YYYYMMDD.csv -Wait をPoweShellで叩けばデータが増えていく様子をリアルタイムで確認できる。

以下は、aliceとtsuchidaの2ユーザがデータを送っている場合

Windows


5. 近距離:Bluetooth SPP

Bluetooth SPP(Serial Port Profile)は、Bluetooth対応のPCやスマートフォンとシリアル通信を行うための機能です。

M5側

以下のコードをM5StickC Plus2に書き込みます。

SerialBT.begin(“M5StickCPlus2”); ← ここの名前については後ろに学籍番号を追記するなど、他の方と被らないように工夫してください!

コード(step05_m5_bt_spp_color.ino
#include <M5StickCPlus2.h>  // M5StickC Plus2 専用のライブラリを読み込む
#include <M5Unified.h>  // M5Stackシリーズを統合的に扱えるライブラリ
#include <BluetoothSerial.h>    // ESP32のBluetooth Classic(SPP)用ヘッダ

BluetoothSerial SerialBT;       // Bluetooth SPP(シリアルポートプロファイル)のインスタンス

void setup() {
  auto cfg = M5.config();       // M5の設定オブジェクト取得(電源/ディスプレイなど)
  M5.begin(cfg);                // M5の初期化
  M5.Display.setRotation(1);    // 画面を横向き(必要に応じて0〜3で回転可)
  M5.Display.fillScreen(TFT_BLACK);  // 画面を黒で塗りつぶし(初期状態)

  // ---- Bluetooth(SPP)開始 ----
  // ここで設定した名前が、PC/AndroidのBluetooth一覧に表示される
  SerialBT.begin("M5StickCPlus2");    

  // 画面にステータス表示
  M5.Display.setCursor(8, 8);         // テキスト描画位置(左上からの座標)
  M5.Display.println("BT SPP ready");  // 初期化完了メッセージ
}

void loop() {
  // 受信バッファにデータが来ているか?
  if (SerialBT.available()) {
    char c = SerialBT.read();   // 1バイト読む('1'や'0'などの文字)

    // 受け取った文字で画面色を切り替え
    if (c == '1') {
      M5.Display.fillScreen(TFT_RED);    // '1' → 画面を赤に
    }
    if (c == '0') {
      M5.Display.fillScreen(TFT_BLACK);  // '0' → 画面を黒に
    }
  }

  M5.delay(1);  // ほんの少し待つ(CPU占有を避けるための慣習的ウェイト)
}

コード解説

if (SerialBT.available()) {
  char c = SerialBT.read();   // 1バイト読む('1'や'0'などの文字)

は、Bluetooth経由でデータを受信するための処理を行っています。

この2行は、プログラムがBluetooth通信でデータを受信したかどうかを確認し、受信していればそのデータを読み取るという役割を果たしています。

1. if (SerialBT.available())

  • SerialBT: これはBluetoothSerialクラスのインスタンスで、このコードではM5StickC Plus2のBluetooth Classic(SPP)通信全体を管理しています。
  • .available(): このメソッドは、Bluetoothの受信バッファ(一時的にデータを保持するメモリ領域)に、まだプログラムが読み取っていないデータが残っているかどうかをチェックします。
    • データが残っている場合(1バイト以上ある場合)、このメソッドは残っているバイト数を返します(戻り値は1以上)。
    • データが残っていない場合、このメソッドは0を返します。
  • なので、if (SerialBT.available()) は「受信バッファに1バイト以上のデータがあるなら」という条件を表します。

Bluetoothで外部(PCやスマートフォンなど)からM5StickC Plus2にデータが送られてきて、それがまだ処理されていなければ、次の行に進みます。これにより、データがないときに無駄に読み取り処理を行うことを防ぎます。

2. char c = SerialBT.read();

  • SerialBT.read():
    • このメソッドは、Bluetoothの受信バッファ最も古い(最初に届いた)1バイトのデータを読み取って取り出します。
      • 読み取られたデータはバッファから削除されます。
      • このコードでは、Bluetooth経由で送られてくるのが文字(例: '1''0')であることを想定しているため、戻り値はchar型として扱われます。
  • char c = ...:
    • 読み取られた1バイトのデータ(ここでは文字)は、char型の変数 c に格納されます。

受信したデータがあることがわかったので、そのデータのうちの1文字(ここでは'1''0'を想定)を実際にプログラムに取り込み、変数cに保存します。

ペアリング & 送信

  1. M5StickC Plus2の画面に「BT SPP ready」と表示されていることを確認してください。
  2. Bluetoothペアリング:
    PC(WindowsまたはMac)のBluetooth設定を開き、デバイス一覧から「M5StickCPlus2」を見つけてペアリングします。
    • Macでは「システム設定」>「Bluetooth」
    • Windowsでは「設定」>「Bluetoothとデバイス」 > デバイスの追加

Mac

Windows

シリアル通信アプリの起動と設定

MacでもWindowsでも、通信できるか確認するにはArduino IDEのシリアルモニタを使うのが簡単で確実です。

  1. Arduino IDEを開く:
    • M5StickC Plus2にコードをアップロードしたArduino IDEを開きます。
  2. ポートの選択:
    • Mac:
      • 「ツール」>「シリアルポート」から、ペアリングしたM5StickC Plus2に対応するBluetoothのシリアルポートを選択します。ポート名は/dev/cu.M5StickCPlus2のような名前になっています。
    • Windows:
      • 「ツール」>「シリアルポート」から、ペアリングしたM5StickC Plus2に対応するBluetoothのCOMポートを選択します。ポート名に「Bluetooth」や「SPP」といったキーワードが含まれていることが多いです。
        ※ 「Unknown」と表示される場合もあり、ちょっとわかりにくいかもです。

  3. シリアルモニタの起動:
    • 画面右上の虫眼鏡アイコンをクリックしてシリアルモニタを開きます。ボーレートはコードで指定していないため、9600 bpsなど一般的な値に設定されていることを確認します。

データの送信と動作確認

  1. データの送信:
    • シリアルモニタ上部の入力欄に、半角で1と入力します。
    • 入力後、エンターキーを押すか、隣の「送信」ボタンをクリックします。
  2. 動作確認:
    • M5StickC Plus2の画面がに変わるか確認します。
  3. 別のデータの送信:
    • 次に、入力欄に半角で0と入力して送信します。
    • M5StickC Plus2の画面がに戻ることを確認します。

今回は、Bluetooth経由でのシリアル通信が正しく動作するかどうかの確認だけを行いましたが、もちろんこれまでと同様に、加速度センサのデータを p5.js と連動させることも可能です。

シリアル通信は、無線制御を行う際にも非常に便利な手段で、M5StickC Plus2 でもおそらく 50〜100Hz 程度の送信が可能だと思います。Python や Node.js との連携も簡単に行えます。

また、シリアル通信は Bluetooth だけでなく、ZigBee(近距離無線通信規格)のような他の規格でも利用できます。たとえば XBee モジュールを使えば、1対多の無線通信を簡単に実現できます。私自身も学生のころ、XBee を使って LED やロボットを制御していました。

このように、ひとくちに無線通信といってもさまざまな方式があり、それぞれにメリットとデメリットがあります。その特徴を理解したうえで、用途に応じて最適な方法を選べるようになってほしいと思います。


6. 近距離:ESP-NOW

※時間ある人向け。M5StickC Plus2が二つ必要になります。

ESP-NOWはWi-Fiを使いますが、Wi-Fiルーター(アクセスポイント)は必要ありません
M5StickC同士が直接通信するため、超低遅延です。

今回は「送信側 → 受信側に1バイトのデータ(画面の色を変えるための番号)を投げる」デモを行います。

M5(受信側)

受信側のMACアドレスを調べる

  • まず、データを受け取る側のM5StickC Plus2に、MACアドレス(デバイス固有の識別番号)を表示させるためのプログラムを書き込みます。
  • 以下のコードをArduino IDEに貼り付け、受信側として使うM5StickC Plus2に書き込みます。
コード(step06_m5_espnow_show_mac.ino)
#include <M5StickCPlus2.h>  // M5StickC Plus2 専用のライブラリを読み込む
#include <M5Unified.h>  // M5Stackシリーズを統合的に扱えるライブラリ
#include <WiFi.h>        // ESP32 の Wi-Fi 機能を使うライブラリ

void setup() {
  auto cfg = M5.config();   
  M5.begin(cfg);                        // M5StickC Plus2 を初期化
  M5.Display.setRotation(1);            // 画面を横向きに設定(0=縦)
  M5.Display.setTextSize(2);            // 文字サイズを少し大きくする

  // ==== MACアドレス取得のための準備 ====
  WiFi.mode(WIFI_STA);                  // Wi-Fi を「STAモード(子機モード)」にする
                                        // → これをしないとMACアドレスが有効化されないことがある

  delay(200);                           // 少し待つ(すぐ読むと 00:00:00:00:00:00 になる場合がある)

  // ==== MACアドレスを取得 ====
  String mac = WiFi.macAddress();       // MACアドレスを文字列で取得

  // ==== 画面に表示 ====
  M5.Display.setCursor(8, 8);           // 表示位置を左上(8,8)にセット
  M5.Display.printf("MAC: %s\n", mac.c_str()); // 画面に MACアドレスを表示

  // ==== シリアルモニタにも出力 ====
  Serial.begin(115200);                 // USBシリアルを有効化(115200bps)
  Serial.println(mac);                  // PC側のシリアルモニタにMACを表示
}

void loop() {
  M5.delay(1000);   // 1秒ごとに軽く待機(特に繰り返し処理はしない)
}

画面に表示されたMACアドレスをメモしておきましょう。

受信側M5StickC Plus2のコード

MACアドレスを控えたら、以下の受信プログラムを同じM5StickC Plus2に書き込みます

コード(受信側:step06_m5_espnow_receiver_color.ino)
#include <M5StickCPlus2.h>  // M5StickC Plus2 専用のライブラリを読み込む
#include <M5Unified.h>  // M5Stackシリーズを統合的に扱えるライブラリ
#include <WiFi.h>             // Wi-Fi機能(ESP-NOWはWi-Fiコアを使う)
#include <esp_now.h>          // ESP-NOW API

// ==== 受信コールバック ====
// 新しいシグネチャ:info(送信元などの情報), data(受信データ), len(長さ)
void onRecv(const esp_now_recv_info* info, const uint8_t* data, int len) {
  if (len < 1) return;                         // データが空なら何もしない

  uint8_t code = data[0];                      // 今回は先頭1バイトのみ使用(0/1)
  if (code == 1) {
    M5.Display.fillScreen(TFT_RED);            // 1 → 画面を赤
  } else {
    M5.Display.fillScreen(TFT_BLACK);          // 0 → 画面を黒
  }
  M5.Display.setTextSize(2);
  M5.Display.setCursor(8, 8);
  M5.Display.print("ESP-NOW  RECEIVER");   // 役割を明示

  // 送信元MACを使いたい場合(ログなど)
  // const uint8_t* mac = info->src_addr;      // 6バイトの送信元MAC
  // Serial.printf("from %02X:%02X:%02X:%02X:%02X:%02X\n",
  //   mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);                               // M5初期化
  M5.Display.setRotation(1);                   // 画面横向き
  M5.Display.fillScreen(TFT_BLACK);            // 初期は黒

  WiFi.mode(WIFI_STA);                         // ESP-NOWはSTAモードが必須

  // ESP-NOW初期化
  if (esp_now_init() != ESP_OK) {
    while (1) {                                // 失敗したら画面に出して待機
      M5.Display.println("esp_now_init fail");
      delay(1000);
    }
  }

  // 受信コールバックを登録(新しい型)
  esp_now_register_recv_cb(onRecv);
}

void loop() {
  M5.delay(1);                                 // 軽いウェイト
}

M5(送信側)

送信側M5StickC Plus2のプログラム

  • 次に、もう1台のM5StickC Plus2を用意し、以下の送信プログラムを書き込みます。
  • peerMacの部分を、先ほど控えた受信側のMACアドレスに書き換えてください
コード(送信側:step06_m5_espnow_sender_toggle.ino)
#include <M5StickCPlus2.h>  // M5StickC Plus2 専用のライブラリを読み込む
#include <M5Unified.h>  // M5Stackシリーズを統合的に扱えるライブラリ
#include <WiFi.h>         // ESP32 の Wi-Fi 機能を利用するためのライブラリ
#include <esp_now.h>      // ESP-NOW 通信を使うためのライブラリ

// ==== 送信先(受信側)の MAC アドレス ====
// 受信側のプログラムで表示される MAC アドレスに置き換えてください。
// 下は例(0C:8B:95:B7:1C:E4 というデバイスに送る)
uint8_t peerMac[6] = {0x0C, 0x8B, 0x95, 0xB7, 0x1C, 0xE4};

// 送信先デバイス(ペア)の情報を保持する構造体
esp_now_peer_info_t peer = {};

void setup(){
  auto cfg = M5.config(); 
  M5.begin(cfg);                          // M5StickC Plus2 を初期化
  M5.Display.setRotation(1);              // 画面を横向きに
  M5.Display.fillScreen(TFT_BLACK);       // 起動時は黒で塗りつぶす
  M5.Display.setTextSize(2);
  M5.Display.setCursor(8, 8);
  M5.Display.print("ESP-NOW  SENDER");     // 役割を明示

  WiFi.mode(WIFI_STA);                    // ESP-NOW利用時は必ず STA モードに設定

  // ESP-NOW の初期化
  if (esp_now_init() != ESP_OK) {         // 初期化失敗した場合
    while (1) {                           // 無限ループでエラーメッセージを表示し続ける
      M5.Display.print("esp_now_init fail");
      delay(1000);
    }
  }

  // ==== 送信先の情報を登録 ====
  memcpy(peer.peer_addr, peerMac, 6);     // 送信先 MAC アドレスをコピー
  peer.channel = 0;                       // チャンネル番号(同じなら0でOK)
  peer.encrypt = false;                   // まずは暗号化なし(簡単に体験するため)
  esp_now_add_peer(&peer);                // 送信先デバイスを「仲間」として登録
}

void loop(){
  static uint32_t t0 = 0; 
  uint32_t now = millis();                // 起動からの経過時間(ミリ秒)

  // ==== 1秒ごとに送信 ====
  if (now - t0 > 1000) {                  // 前回送信から1秒経ったら
    t0 = now;                             // タイマー更新

    static uint8_t code = 0;              // 送信するデータ(0:黒, 1:赤)
    code ^= 1;                            // トグル(0と1を交互に切り替える)

    // 送信先に1バイト送る(&code = データのアドレス, サイズ = 1バイト)
    esp_now_send(peer.peer_addr, &code, 1);
  }

  M5.delay(1);                            // CPU占有を避けるための短い待機
}

このプログラムの動き

  1. 起動時に 受信側のMACアドレスを「仲間」として登録。
  2. loop() の中で 1秒ごとに「0」「1」を交互に送信
  3. 受信側のプログラムは、
    • 0 を受信 → 画面を黒に
    • 1 を受信 → 画面を赤に
      と動作する。

動作確認

  • 2台のM5StickC Plus2の電源を入れます。
  • 送信側M5StickC Plus2が1秒ごとに01を送信し、それを受信側が受け取って画面の色を切り替えます。
  • 受信側の画面が1秒ごとに赤と黒に切り替われば成功です!


7. まとめ:どれを使うか

方式通信形態到達範囲遅延/頻度の目安備考
HTTPリクエスト↔レスポンスWi-Fi圏内遅延やや大
1–10Hz
リアルタイム性は低め
WebSocket常時接続
(1対1/1対多)
Wi-Fi圏内低遅延
10–60+ Hz
即応性◎
MQTTPub/Sub
(多対多)
Wi-Fi圏内〜
インターネット
中遅延
1–50Hz
全員配信に強い
Bluetooth1対1中心(BLE)数m〜十数m低〜中遅延
20–100Hz
(BLE通知)
1対多も繋がるが環境によっては途切れがち
ESP-NOWP2P/1対多見通し数十m超低遅延
100–500Hz
(小パケット)
消費電力小
ZigBee多対多数十〜100m中遅延
10–50Hz
到達性◎


8. 課題

基礎課題

M5StickC Plus2、ブラウザ、そしてNode.jsロガーが同時に動作している様子を1本の動画に収めてください。

提出内容(2つ)

  • ソースコードをMoodleに提出してください。
    • {学籍番号}_{氏名}_第6回基礎課題.ino (M5)
    • index.html(PC)
    • sketch.js (PC)
    • log.csv(加速度センサのデータ)
  • 動作中の動画をMoodleに提出してください。
    • p5.jsの描画とM5StickC Plus2を揺らすなどして加速度データを変化させ、以下の3つの動作が同時に確認できることを示してください。
      • M5StickC Plus2: 揺らしている様子。
      • ブラウザ:
        • 画面上の円がリアルタイムに連動して動く様子。
        • ブラウザをクリックしてM5の画面の色が変わる様子
      • ターミナルなど: 例えばtailコマンドなどでCSVファイルに新しいデータが次々と追記されていく様子。

        動画イメージ:

※ test.mosquito.org は不安定なので、全く反応しない時間が長く続く場合は HiveMQ Cloudを利用するのもありです。無料枠で十分対応できます。ただし、HiveMQ Cloud はTLS必須です。とりあえず動かすために、WiFiClientSecure + setInsecure() にするなど、対応が必要でちょっとハードルが高いです。

発展課題

M5StickC Plus2 の加速度を Bluetooth SPP と p5.js で連動させ、時刻付きで CSV 形式で保存する。

提出内容(2つ)

  • ソースコードを Moodle に提出
    • {学籍番号}_{氏名}_第6回発展課題.ino(M5)
    • index.html / sketch.js(p5.js)
    • log.csv(加速度データ・CSV形式)
    • (その他作成したファイルあれば適宜追加)
  • 動作動画(以下が分かること)
    • ブラウザ描画がM5の動きに追従している
    • p5.js側の描画とCSV保存が同期している