メインページに戻る

このページのゴール

「M5StickC Plus2をWi-Fiルーターにつないで、センサーのデータをWebSocketという方法でパソコンに送り、画面上でリアルタイムに動かす。また、パソコンの画面をクリックすると、M5StickC Plus2の色が変わるようにする。」

今日のねらい

前回はHTTPという通信方法で、データを「取りに行く」という単発のやり取りを学びました。今回は、一度通信を始めたら、切断するまでデータを送り続けられるWebSocketという方法を体験します。これにより、センサーの動きをよりヌルヌルと滑らかに表示できるようになります。


1. HTTP vs WebSocket

前回の復習

前回はHTTPを使って、以下のことを行いました。

  • M5StickC Plus2をWi-Fiにつなぎ、IPアドレスを取得。
  • パソコンのブラウザから/sensorsというURLにアクセスして、定期的にセンサーの値を取りに行きました(この「定期的に取りに行く」ことを「ポーリング」と呼びます)。
  • /fill/redのようなURLにアクセスして、M5StickC Plus2の画面の色を変えました。

この方法では、データを取るにも操作するにも、「毎回リクエスト(要求)を送って、返事をもらう」という往復作業を繰り返していました。

HTTPの限界

この「ポーリング」方式にはいくつか課題があります。

  • 遅延が発生する:例えば100ms(0.1秒)ごとにデータを取りに行っても、最大で100msの遅延が発生してしまいます。もっと滑らかに動かしたい場合には不向きです。
  • 通信の効率が悪い:データの他に毎回数百バイトもある「HTTPヘッダ」という情報をやり取りするので、通信量が増えてしまいます。
  • 双方向のやり取りが苦手:ブラウザ側が常に「取りに行く」しかできないため、M5StickC Plus2側から「こういうイベントが起きたよ!」とすぐに伝えることができません。

WebSocketとは?

HTTPが「郵便」だとすれば、WebSocketは「電話」のようなものだと考えると分かりやすいです。

  • HTTP(郵便):1通の郵便を送るたびに封筒(ヘッダ)を用意し、やり取りする。単発のやり取りには便利ですが、頻繁に行うには手間がかかります。
  • WebSocket(電話):最初に「電話をかける(ハンドシェイク)」という作業を一度だけ行い、一度つながれば、あとは電話を切るまで自由に話せます(双方向)。小さな「メモ(フレーム)」を送り合うだけで済むので、非常に効率的です。

この仕組みのおかげで、WebSocketは以下の点で優れています。

  • 双方向:M5StickC Plus2とブラウザの間で、どちらからも自由にデータを送り合えます。
  • 低遅延:通信のオーバーヘッドが少ないため、ほぼリアルタイムでデータをやり取りできます。
  • 通信量が軽い:一度接続してしまえば、ヘッダ情報は不要なため、小さなデータでも効率よく送受信できます。

イメージ参照URL


2. M5StickC Plus2 :WebSocketサーバ(20Hz送信)

M5StickC Plus2を、ブラウザからの接続を受け付ける「WebSocketサーバー」にします。
今回は加速度センサーとボタンのデータを1秒間に20回(20Hz)送るように設定します。

準備

Arduino IDEの準備

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

M5側

新しいスケッチの作成

ファイル > 新規スケッチ で新しいファイルを開き、以下のコードを全てコピー&ペーストしてください。

Wi-Fi情報とデバイスIDの変更

  • ssidpassを自分のWi-Fi情報に書き換えます。
  • DEVICE_IDm5-01からm5-tsuchidaのように、他の人と重複しないように変更しましょう。
// ===== 自分のWi-Fi情報を書き換えて使用する =====
const char* ssid = "YOUR_SSID";   // Wi-FiのSSID(2.4GHz帯を推奨)
const char* pass = "YOUR_PASS";   // Wi-Fiのパスワード
// 任意のデバイス識別子(複数台接続時の区別に使用)
String DEVICE_ID = "m5-{学籍番号}"; //例:m5-2460320

コード(step02_ws_server_imu_btn.ino)
#include <M5StickCPlus2.h>  // M5StickC Plus2 専用のライブラリを読み込む
#include <M5Unified.h>  // M5Stackシリーズを統合的に扱えるライブラリ
#include <WiFi.h>               // Wi-Fi接続に必要な標準ライブラリ
#include <WebSocketsServer.h>   // WebSocketサーバ機能を提供するライブラリ(arduinoWebSockets)

// ===== 自分のWi-Fi情報を書き換えて使用する =====
const char* ssid = "YOUR_SSID";   // Wi-FiのSSID(2.4GHz帯を推奨)
const char* pass = "YOUR_PASS";   // Wi-Fiのパスワード
// 任意のデバイス識別子(複数台接続時の区別に使用)
String DEVICE_ID = "m5-{学籍番号}"; //例:m5-2460320

// WebSocketサーバをポート81で起動(HTTP:80と分けるのが一般的)
WebSocketsServer ws(81);

// データ送信レート(ミリ秒間隔)。50ms=20Hz, 100ms=10Hz
volatile uint32_t sendIntervalMs = 50;

// 最後に送信した時刻を記録(millis単位)
uint32_t lastSent = 0;

// ---------------------- WebSocketのイベント処理 ----------------------
void onWsEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t len){
  switch(type){
    case WStype_CONNECTED: {   // クライアントが接続してきたとき
      IPAddress ip = ws.remoteIP(num);  // 接続元のIPを取得
      Serial.printf("[WS] client %u connected from %s\n", num, ip.toString().c_str());
      break;
    }
    case WStype_TEXT: {        // クライアントから文字列を受信したとき
      String s = String((const char*)payload, len); // 受信文字列をString化

      // メッセージに"fill":"red"が含まれていれば画面を赤で塗りつぶす
      if(s.indexOf("\"fill\":\"red\"") >= 0){
        M5.Display.fillScreen(TFT_RED);
      }
      // "fill":"black"なら黒にする
      else if(s.indexOf("\"fill\":\"black\"") >= 0){
        M5.Display.fillScreen(TFT_BLACK);
      }

      // メッセージ内に送信レート変更があれば反映
      // 例: {"rate":20} → 20Hz → 1000/20=50ms
      int p = s.indexOf("\"rate\":");
      if(p >= 0){
        int start = s.indexOf(':', p) + 1;   // 数値部分の開始位置
        int end   = s.indexOf('}', start);   // 数値部分の終了位置
        int hz    = s.substring(start, end).toInt(); // 数値を整数に変換

        // 1〜100Hzの範囲でのみ受け付ける
        if(hz >= 1 && hz <= 100){
          sendIntervalMs = 1000 / hz;  // 周期(ms)に変換
          Serial.printf("[WS] rate -> %d Hz (%lu ms)\n", hz, (unsigned long)sendIntervalMs);
        }
      }
      break;
    }
    case WStype_DISCONNECTED: { // クライアントが切断したとき
      Serial.printf("[WS] client %u disconnected\n", num);
      break;
    }
    default:                    // 他のイベントは未処理
      break;
  }
}

// ---------------------- セットアップ処理 ----------------------
void setup(){
  // M5本体を初期化(IMU・ボタン・ディスプレイなど)
  auto cfg = M5.config();
  M5.begin(cfg);

  // ディスプレイの基本設定
  M5.Display.setRotation(1);            // 横向き表示
  M5.Display.setTextSize(2);            // 文字サイズ
  M5.Display.fillScreen(TFT_BLACK);     // 画面を黒で初期化
  M5.Display.setCursor(8, 8);           // 表示位置を左上に設定
  M5.Display.println("WS demo boot...");// 起動画面表示

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

  // 接続後:IPアドレスを画面とシリアルに表示
  M5.Display.fillScreen(TFT_BLACK);
  M5.Display.setCursor(8, 8);
  M5.Display.printf("IP: %s\n", WiFi.localIP().toString().c_str());
  M5.Display.printf("WS: ws://%s:81\n", WiFi.localIP().toString().c_str());

  Serial.begin(115200);   // シリアルモニタ開始
  Serial.printf("IP  : %s\n", WiFi.localIP().toString().c_str());
  Serial.printf("WS  : ws://%s:81\n", WiFi.localIP().toString().c_str());

  // WebSocketサーバ起動 & イベントハンドラ登録
  ws.begin();
  ws.onEvent(onWsEvent);
}

// ---------------------- メインループ ----------------------
void loop(){
  M5.update();   // ボタンやセンサーの状態を更新
  ws.loop();     // WebSocketサーバの処理を実行

  // 現在時刻を取得
  uint32_t now = millis();

  // 設定した送信間隔を超えたらデータ送信
  if(now - lastSent >= sendIntervalMs){
    lastSent = now;  // 最終送信時刻を更新

    // 加速度センサの値を取得
    float ax, ay, az;
    M5.Imu.getAccel(&ax, &ay, &az);

    // ボタン状態を取得(押されていれば1、そうでなければ0)
    int ba = M5.BtnA.isPressed() ? 1 : 0;
    int bb = M5.BtnB.isPressed() ? 1 : 0;

    // JSON形式でデータを組み立て
    // {"id":"...","acc":[x,y,z],"btn":[a,b]}
    String j = "{";
    j += "\"id\":\"" + DEVICE_ID + "\",";
    j += "\"acc\":[" + String(ax,3) + "," + String(ay,3) + "," + String(az,3) + "],";
    j += "\"btn\":[" + String(ba) + "," + String(bb) + "]";
    j += "}";

    // 全ての接続クライアントに送信
    ws.broadcastTXT(j);
  }

  delay(1);  // 1ms休ませてCPU暴走を防ぐ
}

コード解説

  • 基本設定
    • ws(81):WebSocketサーバーを起動します。8180番ポート(HTTP)との混同を避けるための慣習的なポート番号です。
  • イベント処理 (onWsEvent)
    • ブラウザからメッセージが届いた時や、接続/切断が行われた時に呼ばれる関数です。
      • クライアント接続/切断をログ表示。
      • JSON風メッセージを解析して画面色を変更したり、送信レートを変更。
        • WStype_TEXT:ブラウザからテキストデータ(今回はJSON)が送られてきたことを示します。
        • s.indexOf("\"fill\":\"red\""):送られてきたJSONの中に"fill":"red"という文字列が含まれているかチェックし、画面を赤色にします。
        • ws.broadcastTXT(j):JSON文字列jを、接続している全てのブラウザにまとめて送ります。
// ---------------------- WebSocketのイベント処理 ----------------------
void onWsEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t len){
  switch(type){
    case WStype_CONNECTED: {   // クライアントが接続してきたとき
      IPAddress ip = ws.remoteIP(num);  // 接続元のIPを取得
      Serial.printf("[WS] client %u connected from %s\n", num, ip.toString().c_str());
      break;
    }
    case WStype_TEXT: {        // クライアントから文字列を受信したとき
      String s = String((const char*)payload, len); // 受信文字列をString化

      // メッセージに"fill":"red"が含まれていれば画面を赤で塗りつぶす
      if(s.indexOf("\"fill\":\"red\"") >= 0){
        M5.Display.fillScreen(TFT_RED);
      }
      // "fill":"black"なら黒にする
      else if(s.indexOf("\"fill\":\"black\"") >= 0){
        M5.Display.fillScreen(TFT_BLACK);
      }
      ...
        }
      }
      break;
    }
    case WStype_DISCONNECTED: { // クライアントが切断したとき
      ...
    }
    default:                    // 他のイベントは未処理
      break;
  }
}
  • setup()
    • M5StickC Plus2 を初期化し、画面を横向きに設定。
    • Wi-Fi 接続完了まで待ち、IPアドレスを画面&シリアルに表示。
    • WebSocket サーバ開始。
  • loop()
    • WebSocketの維持&受信処理。
    • 一定間隔ごとに IMU の加速度データ+ボタン状態を JSON 形式で生成。
    • ws.broadcastTXT() で全クライアントに配信。

動作イメージ

  • M5StickC Plus2 → PCブラウザ
    加速度センサー値とボタン入力をリアルタイム送信。
  • PCブラウザ → M5StickC Plus2
    JSONメッセージで画面色や送信レートを制御。

動作確認

  1. M5StickC Plus2をPCに接続し、スケッチ > マイコンボードに書き込む を実行します。
  2. 書き込みが完了すると、M5StickC Plus2の画面に「WS: ws://<IP>:81」というURLが表示されます。
    このIPアドレスをメモしておきましょう。

3. p5.js:WebSocketで受信描画&クリック制御

次に、先ほどサーバーにしたM5StickC Plus2に接続し、データを受信して画面を動かすブラウザ側のプログラムを作ります。

準備

  1. step03_ws_client_control_backoffという名前のフォルダを作成し、その中にindex.htmlsketch.jsという2つのファイルを作成します。
  2. Visual Studio Codeなどのエディタでこのフォルダを開き、Live Serverを起動しておくと便利です。

PC側(step03_ws_client_control_backoff)

index.htmlの記述

以下のコードをindex.htmlに貼り付けます。
これは前回とほぼ同じで、p5.jsと自作のスクリプトを読み込むための設定ファイルです。

コード(index.html)
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <title>p5 x WebSocket x M5</title>
  <!-- p5.js 本体(CDNから読み込み) -->
  <script src="https://cdn.jsdelivr.net/npm/p5@1.10.0/lib/p5.min.js"></script>
</head>
<body>
 <main></main>
   <!-- 自作スケッチ -->
  <script src="./sketch.js"></script>
</body>
</html>

sketch.jsの記述

以下のコードをsketch.jsに貼り付けます。
必ずurlの部分を、先ほどメモしたM5StickC Plus2のIPアドレスに書き換えてください。

// ==== 接続先(自分のM5のIPに変更) ====
// 例: "ws://192.168.68.56:81"
let url = "ws://192.168.1.xxx:81"; // ← ここを書き換え
コード(sketch.js)
// ==== 接続先(自分のM5のIPに変更) ====
// 例: "ws://192.168.68.56:81"
let url = "ws://192.168.1.xxx:81";    // ← ここを書き換え

// WebSocketインスタンス
let ws = null;

// 受信データの入れ物
let acc = [0, 0, 0];        // 加速度
let btn = [0, 0];           // ボタンA/B
let deviceId = "—";         // 送ってきた端末ID
let connected = false;      // 接続状態

// 再接続の指数バックオフ(ms)
let backoff = 500;          // 開始 0.5秒
const BACKOFF_MAX = 8000;   // 上限 8秒

// クリック送信用のトグル
let isRed = false;          // 次に送る色(false=black, true=red)

// 1) 接続処理
function connect(){
  // 新しいWebSocketを作成
  ws = new WebSocket(url);

  // 開いたらフラグを立て、バックオフをリセット
  ws.onopen = () => {
    connected = true;
    backoff = 500;
    console.log("[WS] open");
  };

  // メッセージ受信:JSONをパースして画面用の変数を更新
  ws.onmessage = (e) => {
    try{
      const j = JSON.parse(e.data);            // {"id":"..","acc":[..],"btn":[..]}
      deviceId = j.id || deviceId;             // idがあれば更新
      if (Array.isArray(j.acc)) acc = j.acc;   // accがあれば更新
      if (Array.isArray(j.btn)) btn = j.btn;   // btnがあれば更新
      appendCsvSample();
    }catch(err){
      console.warn("bad json:", err);
    }
  };

  // 閉じたら一定時間後に再接続(指数バックオフ)
  ws.onclose = () => {
    connected = false;
    console.warn("[WS] closed; reconnect in", backoff, "ms");
    setTimeout(connect, backoff);
    backoff = Math.min(backoff * 2, BACKOFF_MAX);
  };

  // エラー:とりあえずログ
  ws.onerror = (err) => {
    console.warn("[WS] error:", err);
  };
}

// 2) p5初期化
function setup(){
  createCanvas(600, 360);    // キャンバス
  connect();                 // WS接続開始
  textFont("monospace");     // 情報表示用に等幅
}

// 3) p5描画
function draw(){
  background(245);

  // 円の描画(accでオフセット)
  noStroke(); fill(30);
  const cx = width/2  + acc[0]*60;   // X
  const cy = height/2 + acc[1]*60;   // Y
  circle(cx, cy, 24);

  // 情報パネル
  fill(0); textSize(14);
  text(`WS: ${connected ? "connected" : "reconnecting..."}`, 12, 22);
  text(`url: ${url}`, 12, 42);
  text(`id : ${deviceId}`, 12, 62);
  text(`acc: ${acc.map(v=>v.toFixed(3)).join(", ")}`, 12, 82);
  text(`btn: [A=${btn[0]}, B=${btn[1]}]`, 12, 102);
  text(`click → fill ${isRed ? "red" : "black"}`, 12, 122);
  text(`(R)REC /(S)SAVE CSV`, 12, 142);
}

// 4) クリックで色を送信(交互に red / black)
function mousePressed(){
  if(ws && ws.readyState === WebSocket.OPEN){
    isRed = !isRed;                           // トグル
    const msg = { fill: isRed ? "red" : "black" }; // 送るJSON
    ws.send(JSON.stringify(msg));            // 送信
  }
}

動作確認

  1. Live Serverを起動してブラウザでindex.htmlを開きます。
  2. p5.jsの画面に「WS: connected」と表示されていることを確認します。
  3. M5StickC Plus2を傾けてみてください。ブラウザの円が滑らかに動けば成功です!
  4. p5.jsの画面をクリックすると、M5StickC Plus2の画面の色が切り替われば双方向通信の成功です!

コード解説

接続情報と受信バッファ

let url = "ws://192.168.1.xxx:81";
let ws = null;
let acc = [0, 0, 0];
let btn = [0, 0];
let deviceId = "—";
let connected = false;
let backoff = 500;
const BACKOFF_MAX = 8000;
let isRed = false;
  • urlここを自分のM5のIPに。例:ws://192.168.68.56:81。81はM5側でWSサーバを立てたポート。
  • acc/btn/deviceId:受信JSONをそのままミラーする入れ物(安全にUIへ渡す)。
  • connected:UI表示用の接続フラグ(readyStateだけだと可読性が落ちるため)。
  • backoff/BACKOFF_MAX指数バックオフの待ち時間(ミリ秒)。
  • isRed:クリック時に交互送信する色のトグル。

接続処理 connect()

ws = new WebSocket(url);
ws.onopen = () => { connected = true; backoff = 500; ... };
ws.onmessage = (e) => { ... JSON.parse(e.data) ... 更新 ... appendCsvSample(); };
ws.onclose = () => {
  connected = false;
  setTimeout(connect, backoff);          // 一定時間後に再接続
  backoff = Math.min(backoff * 2, 8000); // 0.5→1→2→4→8秒…
};
ws.onerror = (err) => { console.warn(...); };
  • onopen:接続成功。次回の再接続待機を初期値(0.5s)にリセット
  • onmessage:M5から届くJSON(例:{"id":"m5-01","acc":[x,y,z],"btn":[a,b]})をパースし、UI用変数を更新。
    • deviceId = j.id || deviceId:IDが無いパケットでも直前値を保持
    • Array.isArray(j.acc):型ガードで壊れたデータを無視(安全側)。
    • appendCsvSample()録画機能のフック(5.1のCSV保存を入れている場合だけ意味を持つ)。
  • onclose:切断検出。指数バックオフconnect() を再試行して安定運用。
  • onerror:ログのみ(多くは onclose に続く)。

クリック送信 mousePressed()

if(ws && ws.readyState === WebSocket.OPEN){
  isRed = !isRed;
  const msg = { fill: isRed ? "red" : "black" };
  ws.send(JSON.stringify(msg));
}
  • 接続状態を確認してから送信(未接続時の例外回避)。
  • 交互に {"fill":"red"} / {"fill":"black"} を送信。
    M5側では onWsEvent(WStype_TEXT) で受信して fillScreen(TFT_RED/BLACK) を実行する想定。

レート制御&再接続の理解

レート制御

レート制御は、Wi-Fiでデータを送る「速さ」を調整する仕組みです。

M5StickC Plus2は、センサーの情報を絶えずブラウザに送る「速報レポーター」のようなものです。

  • sendIntervalMs(送信間隔):
    • レポーターが「次の速報を送るまで何秒待つか」という時間です。
    • コードでは、初期値が50msに設定されています。つまり、50ミリ秒ごとに新しい速報を送ります。
  • Hz(ヘルツ):
    • これは、1秒間に何回速報を送るかという「頻度」を表す単位です。
    • 1秒は1000ミリ秒なので、1000ms ÷ 50ms = 20Hzとなり、1秒間に20回データを送っていることになります。

なぜレート制御が大切なのか?

速報の頻度を上げすぎると、M5StickC Plus2の小さなCPUやWi-Fiチップがパンクしてしまいます。

例えば、1秒間に100回も送ろうとすると、処理が追いつかなくなって、せっかくの動きがギクシャクしたり、データが途中で消えたりする原因になります。

今回の授業で10〜20Hzあたりを目安にしているのは、M5StickC Plus2が無理なく、スムーズにデータを送れるちょうどいい速さだからです。

再接続戦略

インターネットの世界では、Wi-Fiの電波が弱くなったり、サーバーが一時的に止まったりして、接続が切れてしまうことがよくあります。そのため、「接続はいつか切れるもの」と考えて、「切れても自動で元に戻る」仕組みを作っておくことが、とても重要になります。

なぜ再接続が必要なのか?

優秀なレポーターが、もし電話回線が切れても、諦めずに自動でかけ直すのと同じイメージです。
最初からこの仕組みを入れておくことで、システムがもっと安定して動くようになります。

指数バックオフ(Exponential Backoff)

「再接続の試み方」です。
レポーターがTV局に電話をかけ直す場面を想像してみてください。

  1. 最初の試み: 電話が通じなかったら、まず0.5秒だけ待って、もう一度かけ直してみます。
  2. 失敗したら: もしまた通じなければ、次は待ち時間を2倍にして、1秒待ちます。
  3. さらに失敗したら: それでもダメなら、また待ち時間を2倍にして、2秒、4秒、8秒…と、どんどん間隔を空けていきます。

この待ち時間を倍々ゲームで増やしていくやり方が「指数バックオフ」です。
この方法を使うと、サーバーに接続できない状態が続いても、サーバーに何度も連続して負荷をかけるのを防ぎ、サーバーが回復するのを辛抱強く待つことができます。

これにより、自動的に、そしてサーバーに負担をかけずに、「いつか必ずつながる」まで再接続を試み続けることができます。

指数バックオフが実装されている部分(sketch_ws.js)

1. ws.onclose 関数:

WebSocketの接続が切れたときに呼び出される部分です。

ws.onclose = () => {
  connected = false;
  console.warn("[WS] closed; reconnect in", backoff, "ms");
  setTimeout(connect, backoff);
  backoff = Math.min(backoff * 2, BACKOFF_MAX);
};

この部分が、指数バックオフの心臓部です。

  • setTimeout(connect, backoff);: 接続が切れた後、backoffで指定されたミリ秒だけ待ってから、connect関数(再接続処理)を呼び出します。
  • backoff = Math.min(backoff * 2, BACKOFF_MAX);: 次に接続が切れたときのために、待ち時間であるbackoffの値を2倍に増やしています。これにより、再接続に失敗するたびに、待ち時間が500ms1000ms2000ms…と倍増していきます。Math.min()を使うことで、待ち時間がBACKOFF_MAX(8000ms、つまり8秒)を超えないように上限を設けています。

2. ws.onopen 関数:

接続が成功したときに呼び出される部分です。

ws.onopen = () => {
  connected = true;
  backoff = 500;
  console.log("[WS] open");
};
  • backoff = 500;: 接続が一度でも成功したら、待ち時間を初期値の500msにリセットしています。これにより、次に接続が切れたときに、また最初の短い間隔から再接続を試みることができます。

これらの組み合わせにより、「接続が切れるたびに待ち時間を倍増させて再接続を試み、接続が成功したら待ち時間をリセットする」という、指数バックオフの賢い戦略が実現されています。これは、サーバーへの無駄な負荷をかけずに、より確実に再接続するための、クライアント側のテクニックです。


4. センサ値の保存

ライブで動きを確認するだけでなく、後からデータを解析するために記録しておきましょう。
ここでは2つの方法を学びます。

4.1 p5.jsだけで簡単CSV保存(手軽な方法)

ブラウザ上で「R」キーを押すと録画を開始し、「S」キーを押すとCSVファイルがダウンロードされます。

PC側(step04_ws_client_csv_logger)

以下のコードを「step03_ws_client_control_backoff」のsketch.js一番最後に追記してください。

コード(sketch.js)
// ==== 追記:p5だけでCSV保存(手軽) ====
// → ブラウザ(p5.js)だけでログをCSV化してダウンロードする仕組み。

// -------------------- 録画制御 --------------------
let recording = false;                     // 録画フラグ。trueなら受信データをバッファ(rows)に溜める

// -------------------- データ格納 --------------------
let rows = [];                             // CSV用の行バッファ
const ROW_LIMIT = 200000;                  // 安全装置。20万行以上は溜めない(ブラウザが落ちるのを防止)

// -------------------- 録画UI(RECバッジ) --------------------
let recBadge = null;

// 初回だけRECバッジを作る
function ensureRecBadge(){
  if(!recBadge){
    recBadge = createDiv('● REC');
    recBadge.style('position','fixed');
    recBadge.style('top','12px');
    recBadge.style('right','12px');
    recBadge.style('padding','6px 10px');
    recBadge.style('font-family','monospace');
    recBadge.style('font-size','14px');
    recBadge.style('background','#fff');
    recBadge.style('border','1px solid #e33');
    recBadge.style('border-radius','6px');
    recBadge.style('box-shadow','0 2px 6px rgba(0,0,0,.1)');
    recBadge.style('color','#e33');
    recBadge.style('display','none'); // 初期は非表示
  }
}

// 録画状態に応じて表示/更新
function updateRecBadge(){
  ensureRecBadge();
  if(!recBadge) return;
  if(recording){
    // ヘッダ行を除いた実レコード数を表示
    const n = Math.max(0, rows.length - 1);
    recBadge.html(`● REC <span style="color:#000">(${n} rows)</span>`);
    recBadge.style('display','block');
  }else{
    recBadge.style('display','none');
  }
}

// CSVの初期化(ヘッダ行を作成)
function initCsv(){
  rows = [];                               // バッファを空に
  rows.push(["client_iso","id","ax","ay","az","btnA","btnB"]);
  // ↑ ヘッダ:受信時刻、デバイスID、加速度3軸、ボタン状態
  updateRecBadge();
}

// 1レコード追記(受信時に呼ぶ)
function appendCsvSample(){
  if(!recording) return;                   // 録画中でなければ何もしない
  if(rows.length >= ROW_LIMIT) return;     // 上限を超えたら停止
  const iso = new Date().toISOString();    // 現在時刻をISO形式で文字列化
  rows.push([iso, deviceId, acc[0], acc[1], acc[2], btn[0], btn[1]]);
  // ↑ deviceId, acc[], btn[] は onmessage 側で受信した値を利用する前提
  updateRecBadge();                        // 行数を即時反映
}

// CSVをファイルとして保存(ダウンロード)する処理
function saveCsv(){
  const bom = "\ufeff";                    // Excelで文字化け防止用のBOM
  const csv = rows.map(r => r.join(",")).join("\n"); // 配列をCSV文字列に変換
  const blob = new Blob([bom + csv], {type:"text/csv"}); // Blob化してファイル風に
  const url = URL.createObjectURL(blob);   // 一時URLを作成
  const a = document.createElement("a");   // ダウンロード用のリンク要素
  const ts = new Date().toISOString().replace(/[:.]/g,"-"); // ファイル名用タイムスタンプ
  a.href = url;
  a.download = `m5log_${ts}.csv`;          // 例: m5log_2025-09-22T12-34-56.csv
  a.click();                               // 自動クリックで保存開始
  URL.revokeObjectURL(url);                // 不要になったURLを破棄
}

// -------------------- WebSocketと結合 --------------------
// onmessage の最後に appendCsvSample() を呼べばOK。
const _onMsg = ws?.onmessage;              // 参照保持(上書きはしない)

// -------------------- キーボード操作 --------------------
// Rキー: 録画開始/停止トグル
// Sキー: CSV保存
function keyPressed(){
  if(key.toLowerCase()==="r"){             // Rキー
    recording = !recording;                // true/falseを切り替え
    if(recording && rows.length<=1) initCsv(); // 録画開始時に初期化
    updateRecBadge();                      // UI更新(開始/停止の可視化)
  }
  if(key.toLowerCase()==="s"){             // Sキー
    saveCsv();                             // 保存実行(録画は継続/停止はしない)
  }
}

コードの解説

1. データを入れる箱の準備

let recording = false;
let rows = [];
const ROW_LIMIT = 200000;

function initCsv(){
  rows = [];
  rows.push(["client_iso","id","ax","ay","az","btnA","btnB"]);
}
  • recording は「録画中かどうか」を示すスイッチです。最初はfalse(録画していない状態)です。
  • rows は、受け取ったデータを一時的に入れておくための箱です。データはここにどんどん溜まっていきます。
  • ROW_LIMIT は、箱に載せるデータの量に制限を設けています。200000行(20万行)を超えると、ブラウザが重くなって動かなくなるのを防ぐための「安全装置」です。
  • initCsv() は、箱を空にし、一番上に「何を入れるかの見出し(ヘッダー)」を書いておくための関数です。

2. データを箱に入れていく

function appendCsvSample(){
  if(!recording) return;
  if(rows.length >= ROW_LIMIT) return;
  const iso = new Date().toISOString();
  rows.push([iso, deviceId, acc[0], acc[1], acc[2], btn[0], btn[1]]);
}
  • appendCsvSample() は、M5Stickから新しいデータが届くたびに呼ばれる関数です。
  • if(!recording) return;
    • もし録画中でなければ、何もしないですぐに処理を終えます。
  • if(rows.length >= ROW_LIMIT) return;
    • 箱が満杯なら、それ以上データを追加しないようにします。
  • rows.push(...)
    • ここに新しいデータを「1行」として箱に追加しています。
      具体的には「現在の時刻、デバイスID、加速度(acc)、ボタンの状態(btn)」といった情報です。

3. データを「ファイル」として保存する

function saveCsv(){
  const bom = "\ufeff";
  const csv = rows.map(r => r.join(",")).join("\n");
  const blob = new Blob([bom + csv], {type:"text/csv"});
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  const ts = new Date().toISOString().replace(/[:.]/g,"-");
  a.href = url;
  a.download = `m5log_${ts}.csv`;
  a.click();
  URL.revokeObjectURL(url);
}
  • saveCsv() は、箱にたまったデータを「CSVファイル」にするための処理です。
  • csv = ... .join("\n")
    • お皿の中のデータを1行ずつ「,(カンマ)」と「改行」でつないで、一つの大きな文字列に変換しています。これがCSV形式のデータになります。
  • const blob = new Blob(...)
    • このCSV文字列を、パソコンが「あ、これはファイルなんだな」と認識できる形(Blob)に変換します。bomは、エクセルで開いたときに文字化けしないようにするための処理です。
  • const a = document.createElement("a")
    • ブラウザの画面には見えない「ダウンロード用のボタン」を作ります。
  • a.href = url;a.download = ...:
    • この見えないボタンに「このデータを、こんなファイル名でダウンロードしてね」という情報を設定します。
  • a.click();
    • 最後に、この見えないボタンをプログラムで自動的に「カチッ」とクリックすることで、ダウンロードが始まります。

4. キーボード操作との連動

function keyPressed(){
  if(key.toLowerCase()==="r"){
    recording = !recording;
    if(recording && rows.length<=1) initCsv();
  }
  if(key.toLowerCase()==="s"){
    saveCsv();
  }
}
  • keyPressed() は、キーボードが押されたときに自動で呼ばれる関数です。
  • if(key.toLowerCase()==="r")
    • もし押されたキーがRだったら、recordingのスイッチをON/OFF切り替えます。
      もしRキーを押して録画が始まる状態なら、initCsv()を呼んで新しい箱を準備します。
  • if(key.toLowerCase()==="s")
    • もし押されたキーがSだったら、saveCsv()を呼んで、たまったデータをファイルとして保存します。

これで、ブラウザ上で簡単にセンサーデータの記録と保存ができるようになります。

特徴

  • 準備が不要で、ブラウザだけで完結します。
  • しかし、ブラウザのタブを閉じたり、PCがスリープしたりすると録画も止まってしまいます。長時間の記録には不向きです。

4.2 Node.jsロガーで裏で保存

M5StickC Plus2は複数の機器と同時に通信できるので、ブラウザでセンサーの動きを目で見ながら、別のパソコンで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)
  • 授業の位置づけ
    • 裏方(バックエンド)
    • クラス全員分のデータを自動でロギングしたり、後から解析できるように保存する「記録係」

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 step05_ws_stream_logger
cd step05_ws_stream_logger
npm init -y
npm i ws --no-audit --no-fund --no-optional --ignore-scripts --verbose
mkdir logs

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

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

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

logger.jsの作成

VSCodeを使ってws-loggerフォルダ内にlogger.jsを作成し、以下のコードを貼り付けます。
WS_URLのIPアドレスを自分のM5StickC Plus2のIPアドレスに書き換えてください。

// ---- 接続先のWebSocketサーバ(M5側のIPに書き換える) ----
// 例: ws://192.168.68.56:81
const WS_URL = "ws://192.168.XX.XX:81";
コード(logger.js)
// Node.js: ESP32(M5StickC Plus2など)のWebSocketから受信 → CSVに裏で保存するロガー
// ----------------------------------------------------------
// 実行例:  node logger.js
// 必要:    npm install ws
// 出力:    m5log_YYYY-MM-DD_hh-mm-ss.csv というCSVが10分ごとに新規作成される
// ----------------------------------------------------------

// ファイルを操作するための道具(モジュール)を読み込む
const fs = require("fs");                 
// WebSocket通信を行うための道具(ライブラリ)を読み込む
const WebSocket = require("ws");          
// ファイルのパス(場所)を扱うための道具を読み込む
const path = require("path");

// ---- 接続先のWebSocketサーバ(M5側のIPに書き換える) ----
// 例: ws://192.168.68.56:81
const WS_URL = "ws://192.168.XX.XX:81";

// ---- ファイル管理関連の設定 ----
const ROTATE_MINUTES = 10;                // 1ファイルの長さ(10分ごとに新しいCSVに切替)
let stream = null;                        // 現在書き込み中のファイルストリーム
let rotateTimer = null;                   // ローテーション用タイマー

// ---- 再接続制御用 ----
let ws = null;                            // WebSocketインスタンス
let backoff = 500;                        // 再接続待機(ms) 初期値0.5秒
const BACKOFF_MAX = 8000;                 // 最大待機時間は8秒
const LOG_DIR = path.join(__dirname, 'logs');

// ---- ファイル名を生成(タイムスタンプ入り) ----
function makeFilename(){
  const d = new Date();
  const z = (n)=>String(n).padStart(2,"0"); // 2桁ゼロ埋め
  // 例: m5log_2025-09-22-11-23-11.csv のようなファイル名を生成
  const fname = `m5log_${d.getFullYear()}-${z(d.getMonth()+1)}-${z(d.getDate())}_${z(d.getHours())}-${z(d.getMinutes())}-${z(d.getSeconds())}.csv`;
  return path.join(LOG_DIR, fname);        // logs フォルダに入れる
}

// ---- 新しいCSVファイルを開く ----
function openNewFile(){
  // logsフォルダがなければ作成
  if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive:true });
  // もしすでにファイルが開かれていたら、一度閉じる
  if(stream) stream.end();                
  const name = makeFilename();            // 新しいファイル名
  // ファイルを書き込みモードで開く
  stream = fs.createWriteStream(name, { flags: "a" }); 
  // ファイルの先頭にBOMを入れて、Excelで開いたときに文字化けするのを防ぐ
  stream.write("\ufeff");                 
  // ファイルの1行目に見出し(ヘッダ)を書き込む
  stream.write("client_iso,device_id,ax,ay,az,btnA,btnB\n"); 
  // ログを出力して、作業開始を知らせる
  console.log("[LOG] start:", name);      
}

// ---- 定期的にファイルを切り替える(ローテーション) ----
function startRotation(){
  // もしすでに動いているタイマーがあれば止める
  if(rotateTimer) clearInterval(rotateTimer);        
  // 指定した時間(ROTATE_MINUTES)ごとにopenNewFile関数を呼び出すタイマーをセット
  rotateTimer = setInterval(openNewFile, ROTATE_MINUTES*60*1000); 
}

// ---- JSONデータを1行CSVに書き出す ----
function writeRow(j){
  // ファイルが開かれていなければ、何もせずに処理を終える
  if(!stream) return;                     
  const iso = new Date().toISOString();   // PC側の受信時刻
  const id  = j.id || "";                 // デバイスID
  // 受け取ったデータが正しいか確認し、正しくなければ「NaN」(数字ではない)と設定
  const acc = Array.isArray(j.acc) ? j.acc : [NaN,NaN,NaN]; 
  const btn = Array.isArray(j.btn) ? j.btn : [NaN,NaN];     
  // カンマで区切った1行の文字列を、ファイルに書き込む
  stream.write(`${iso},${id},${acc[0]},${acc[1]},${acc[2]},${btn[0]},${btn[1]}\n`);
}

// ---- WebSocket接続処理(再接続ロジック付き) ----
function connect(){
  console.log("[WS] connect:", WS_URL);
  ws = new WebSocket(WS_URL);

  // 接続が成功したとき
  ws.on("open", () => {
    console.log("[WS] open");
    // 再接続の待ち時間を初期値に戻す(成功したので、次はすぐに試せるように)
    backoff = 500;                        
  });

  // メッセージを受信したとき
  ws.on("message", (buf) => {
    try{
      const j = JSON.parse(buf.toString()); // 受け取ったJSONデータを解析
      writeRow(j);                          // CSVに1行書き込み
    }catch(e){
      console.warn("[WS] bad json:", e.message); // もしJSONが壊れていたら警告
    }
  });

  // 接続が切れたとき
  ws.on("close", () => {
    console.warn("[WS] closed; reconnect in", backoff, "ms");
    // 一定時間待ってから、自分自身(connect関数)を再度呼び出し
    setTimeout(connect, backoff);          
    // 次に切れたときのために、待ち時間を2倍にする(最大8秒まで)
    backoff = Math.min(backoff*2, BACKOFF_MAX); 
  });

  // エラーが発生したとき
  ws.on("error", (e) => {
    console.warn("[WS] error:", e.message);
  });
}

// ---- プログラムの実行開始 ----
openNewFile();    // 最初のCSVファイルを作成
startRotation();  // ファイルを切り替えるタイマーを開始
connect();        // M5StickC Plus2への接続を開始

コード解説

1. 準備と設定

// 必要な道具を読み込む
const fs = require("fs");         // ファイル操作用
const WebSocket = require("ws");  // WebSocket通信用
const path = require("path");     // ファイルの場所を扱う用

// ---- 接続先と各種設定 ----
const WS_URL = "ws://192.168.68.67:81";
const ROTATE_MINUTES = 10;
const BACKOFF_MAX = 8000;
const LOG_DIR = path.join(__dirname, 'logs');

// ---- プログラムで使う変数 ----
let ws = null;
let backoff = 500;
let stream = null;
let rotateTimer = null;

まず、プログラムが動くために必要なものをすべて用意する部分です。
ここには、外部の道具(ライブラリ)の読み込み、M5StickC Plus2のIPアドレス、そして記録のための設定値や変数が書かれています。

  • require() で読み込んでいるのが、このプログラムで使う「道具」。
  • const と書かれている行は、変わらない「設定値」です。
    特に WS_URL は、今手元で使っているM5StickC Plus2に合わせて書き換える必要があります。

2. メイン機能

// ---- ファイル名を生成(タイムスタンプ入り) ----
function makeFilename(){
  // ... コード ...
}

// ---- 新しいCSVファイルを開く ----
function openNewFile(){
  // ... コード ...
}

// ---- 定期的にファイルを切り替える(ローテーション) ----
function startRotation(){
  // ... コード ...
}

// ---- JSONデータを1行CSVに書き出す ----
function writeRow(j){
  // ... コード ...
}

// ---- WebSocket接続処理(再接続ロジック付き) ----
function connect(){
  // ... コード ...
}

この部分には、プログラムのメインとなる4つの機能が、それぞれ関数としてまとまっています。

ファイル管理

  • makeFilename(): 新しいCSVファイルの名前を生成する。
  • openNewFile(): 新しいファイルを開き、見出し(ヘッダ)を書き込む。
  • startRotation(): 10分ごとに新しいファイルに自動で切り替えるタイマーを設定する。

データ処理

  • writeRow(j): 受け取ったセンサーデータをCSVの1行に整形し、ファイルに書き込む。

通信制御

  • connect(): M5StickC Plus2への接続と再接続を行う。通信が切れたときに自動でつなぎ直すための工夫(指数バックオフ)もここに書かれています。

ポイント:

  • それぞれの関数の名前とコメントを読めば、その関数が何のための機能なのかがわかります。
  • writeRow()の中を見れば、受け取ったデータがどのように並べられてCSVになるのかが理解できます。
  • connect() の中を見れば、接続が成功・失敗したときに何が起きるかがわかります。

3. プログラムの実行

// ---- プログラムの実行開始 ----
openNewFile();    // 最初のCSVファイルを作成
startRotation();  // ファイルを切り替えるタイマーを開始
connect();        // M5StickC Plus2への接続を開始

最後に、これまで準備した機能のスイッチをONにする部分です。
この3つのコマンドが、プログラムの起動時に一度だけ実行されます。

ポイント:

  • openNewFile() が最初に呼ばれるので、プログラムが始まるとすぐに最初のCSVファイルが作成されます。
  • connect() が呼ばれて、M5StickC Plus2への接続が始まります。
  • この3行は、プログラムが動き出すための出発点です。

起動する

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

node logger.js
  • [LOG] start:というログが表示され、M5StickC Plus2からデータが送られてくるたびにm5log_YYYY-MM-DD_hh-mm-ss.csvファイルが自動的に作成され、記録されていきます。
  • 停止方法:Ctrl + C

確認する

  • フォルダws-logger/logs/
  • ファイルm5log_YYYY-MM-DD_hh-mm-ss.csv
  • 中身例
client_iso,device_id,ax,ay,az,btnA,btnB
2025-09-22T02:30:49.918Z,m5-01,0.17,0.009,0.949,0,0
2025-09-22T02:30:49.968Z,m5-01,0.162,0.004,0.956,0,0
...
  • tail -f m5log_YYYY-MM-DD_hh-mm-ss.csvをターミナル等で叩けばデータが増えていく様子をリアルタイムで確認できる。

5. 課題

基礎課題

M5の加速度センサとp5.jsをWebSocektを用いて連動させ、加速度値を時刻付きでcsvファイルに保存する。

提出内容(2つ)

  • ソースコードをMoodleに提出してください。
    • ~~~.ino (M5)
    • index.html(PC)
    • sketch.js (PC)
    • log.csv(加速度センサのデータ)
  • 動作中の動画をMoodleに提出してください。
    • 条件(動画を見て以下がわかること)
      • p5.jsの描画と連動していること
      • p5.js側の操作でM5StickC Plus2が制御できていること

発展課題

加速度センサーの値を使ったp5.js上で動くゲームを作ってみてください。

提出内容(2つ)

  • ソースコードをMoodleに提出してください。
    • ~~~.ino (M5)
    • index.html(PC)
    • sketch.js (PC)
    • readme.txt(内容説明)
  • 動作中の動画をMoodleに提出してください。

これで第5回は完了です。

  • 双方向リアルタイム(WebSocket)
  • 再接続設計(指数バックオフ)
  • レート制御の感覚(10–20Hz)
  • CSV保存(手軽or本格)

次回はこれを踏まえて、クラウド(MQTT)&近距離連携(ESP-NOW/Bluetooth)に進みます。