- 概要
- Step1 データ生成(PC/Python):p5.jsで動画からLEDを光らせるための時刻付きCSVデータを書き出す
- Step2: M5StickC Plus2 SDカード読み込みテスト
- Step3: millis()に合わせてSDカード読み込みテスト
- Step4: UDPによる時刻同期 ← イマココ
- 課題
Step4: UDPによる時刻同期
前回のStep3ではM5StickC単体での再生を行いましたが、Step4ではいよいよPC(p5.js)と連携させます。 PC側で再生している動画の「現在時刻」を、Wi-Fi(UDP通信)を経由してM5StickCに通知し、ズレを自動補正するシステムを構築します。
システムの仕組み
このシステムは「PCが指揮者、M5StickCが演奏者」の関係になります。
- PC (p5.js):
- 自動送信:
- 動画再生中は 1秒おき に「現在の再生時間(ミリ秒)」を送信します。
- 手動操作:
- シークバーを操作した瞬間や、デバッグ用コマンド(’c’キー)なども送信します。
- シークバーを操作した瞬間や、デバッグ用コマンド(’c’キー)なども送信します。
- 自動送信:
- M5StickC Plus2 (受信機):
- 基本動作:
- 普段は自分の時計(
millis())を使って、自律的にSDカードのデータを読み進めます(Step3と同じ)。
- 普段は自分の時計(
- 同期補正:
- PCから「今は1000msだよ!」という時刻データが届くと、自分の持っている時刻とのズレ(オフセット)を修正し、ピタッと合わせます。
- PCから「今は1000msだよ!」という時刻データが届くと、自分の持っている時刻とのズレ(オフセット)を修正し、ピタッと合わせます。
- 基本動作:

1. PC側:p5.js (sketch.js)
PC側のプログラムです。 sketch.js を以下のコードに書き換えてください。
※ bridge.js と index.html は前回の「step05_video_color_udp_sync」のままでOKです。
bridge.js
/**
* UDP Bridge Server (Node.js)
* 役割: p5.js (WebSocket) と M5StickC (UDP) の間の通訳
* 理由: ブラウザは直接UDP通信ができないため、このNode.jsアプリが仲介します。
*/
// --- 1. 必要なライブラリの読み込み ---
const WebSocket = require('ws'); // WebSocket通信用
const dgram = require('dgram'); // UDP通信用
// --- 2. 通信設定(定数) ---
const WS_PORT = 8080; // p5.js (ブラウザ) からの接続を受け付けるポート
const UDP_PORT = 8000; // M5StickC (マイコン) へデータを送信する先のポート
// ========== 送信先の設定(どちらか片方を選んでください) ==========
// 【パターンA】 全員に送る(ブロードキャスト) ※現在はこっちが有効
// '255.255.255.255' を指定すると、同じWi-Fi内の全デバイスに届きます
// const UDP_HOST = '255.255.255.255'; //ブロックしがち
const UDP_HOST = '192.168.1.255'; //ルーター繋がっている人のみ
// 192.168.1.255 (ディレクテッド・ブロードキャスト):
// 「192.168.1.xxx のグループの皆さん!」と宛先を絞った投げ方です。
// 【パターンB】 特定の1台だけに送る(ユニキャスト)
// ※ 特定の相手に送る場合は、上のパターンAをコメントアウト(//)し、
// 下のコメント(//)を外して、M5の画面に表示されたIPアドレスを書いてください。
// const UDP_HOST = '192.168.1.104'; // 例: M5の画面に出ているIP
// =============================================================
// --- 3. UDPクライアント(送信機)の準備 ---
// IPv4形式のUDPソケット(通信の出入り口)を作成します
const udpClient = dgram.createSocket('udp4');
// ソケットをシステムにバインド(紐付け)して準備完了状態にします
udpClient.bind(() => {
// ブロードキャスト(一斉送信)機能を有効にします
// ※パターンB(特定送信)の場合でも、この行はそのままで問題ありません
udpClient.setBroadcast(true);
});
// --- 4. WebSocketサーバー(受信機)の準備 ---
// 指定したポートでWebSocketサーバーを立ち上げます
const wss = new WebSocket.Server({ port: WS_PORT });
// サーバーが起動したことをターミナルに表示します
console.log(`Bridge running... WS:${WS_PORT} -> UDP:${UDP_HOST}:${UDP_PORT}`);
// --- 5. 通信のメイン処理 ---
// ブラウザ(p5.js)から接続があったときに実行されるイベント
wss.on('connection', ws => {
console.log('p5.js connected!');
// ブラウザからメッセージ(色データなど)が届いたときに実行されるイベント
ws.on('message', message => {
// ★デバッグ用:届いたデータをターミナルに表示!
// これが表示されなければ、p5.js側がおかしいです
console.log(`RX: ${message}`);
// 受け取ったメッセージをUDPで送れる「Buffer(バイト列)」に変換します
const msgBuffer = Buffer.from(message.toString());
// 変換したデータをUDPを使って送信します
udpClient.send(msgBuffer, 0, msgBuffer.length, UDP_PORT, UDP_HOST, (err) => {
if (err) console.error(err);
});
});
});
// --- 6. 終了処理(お行儀よく終わる設定) ---
// ターミナルで「Ctrl + C」が押されたときを検知します
process.on('SIGINT', () => {
console.log('\n[SYS] SIGINT: closing...');
udpClient.close(); // UDPソケットを閉じる
wss.close(() => process.exit(0)); // WebSocketサーバーを閉じて終了
});
index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UDP</title>
<script src="https://cdn.jsdelivr.net/npm/p5@1.10.0/lib/p5.min.js"></script>
<style>
body {
margin: 0;
font: 14px/1.4 system-ui;
overflow: hidden;
}
#ui {
position: fixed;
left: 8px;
top: 8px;
background: #0008;
color: #fff;
padding: 8px;
border-radius: 8px;
}
</style>
</head>
<body>
<div id="ui">
status: <span id="st">connecting...</span><br>
RGB: <span id="rgb">-</span>
</div>
<script src="./sketch.js"></script>
</body>
</html>
主な変更点:
socket.send()を使って、定期的に動画の現在時刻(ミリ秒)を送信する処理を追加しました。- シークバー(画面下部の赤いバー)をクリックした際、即座にその時間を送信してM5側の反応を良くしています。
コード(sketch.js)
// === 設定 ===
const LED_NUM = 15;
const CANVAS_W = 320;
const CANVAS_H = 240;
const SYNC_INTERVAL = 500; // 0.5秒ごとに時刻同期
// === グローバル変数 ===
let vid;
let socket;
let rgbText;
let lastSendTime = 0;
const wsUrl = 'ws://localhost:8080';
function preload() {
vid = createVideo("clip0.mp4");
}
function setup() {
createCanvas(CANVAS_W, CANVAS_H);
vid.size(CANVAS_W, 180);
vid.hide();
vid.loop(); // 自動再生開始
rgbText = document.getElementById("rgb");
// WebSocket接続
socket = new WebSocket(wsUrl);
socket.onopen = () => console.log("WS Connected");
}
function draw() {
background(20);
// === 動画描画 (Coverモード) ===
let vidRatio = vid.width / vid.height;
let canvasRatio = width / height;
let w, h;
if (canvasRatio > vidRatio) {
w = width; h = w / vidRatio;
} else {
h = height; w = h * vidRatio;
}
let x = (width - w) / 2;
let y = (height - h) / 2;
image(vid, x, y, w, h);
// === 現在時刻の取得 ===
let currentSeconds = vid.time(); // 現在の秒数 (小数)
let totalSeconds = vid.duration(); // 動画の長さ (秒)
let currentMs = floor(currentSeconds * 1000);
// === シークバー描画 ===
// 画面の一番下に進行状況バーを表示
let progress = currentSeconds / totalSeconds;
noStroke();
fill(100);
rect(0, height - 10, width, 10); // 背景バー
fill(255, 0, 0);
rect(0, height - 10, width * progress, 10); // 進捗バー
// === UIテキスト ===
fill(255); textSize(16);
text(`Time: ${currentMs} ms`, 20, 30);
text("[Space]:Play/Pause [c]:Debug [Click Bar]:Seek", 20, 50);
// === プレビュー ===
let c = get(width / 2, height / 2);
rgbText.innerHTML = `${c[0]}, ${c[1]}, ${c[2]}`;
fill(c); rect(width - 60, height - 60, 50, 50);
// === 時刻送信 (Sync) ===
if (socket.readyState === WebSocket.OPEN) {
// 再生中、かつ一定時間経ったら送信
// ※シーク直後などに即座に送るためのフラグ管理もできますが、
// 今回はシンプルに定期送信のみにしています
if (millis() - lastSendTime > SYNC_INTERVAL) {
lastSendTime = millis();
socket.send(currentMs.toString());
}
}
}
// === マウス操作 (シーク機能) ===
function mousePressed() {
// 画面下部(シークバーエリア)をクリックしたら
if (mouseY > height - 20) {
let totalSeconds = vid.duration();
// クリックした横位置(mouseX)から新しい時間を計算
let newTime = map(mouseX, 0, width, 0, totalSeconds);
// 動画をジャンプさせる!
vid.time(newTime);
// ジャンプした瞬間の時間をすぐにM5へ送る(反応を良くするため)
let newMs = floor(newTime * 1000);
if (socket.readyState === WebSocket.OPEN) {
socket.send(newMs.toString());
}
}
}
// === キー操作 ===
function keyPressed() {
if (key === 'c' || key === 'C') {
if (socket.readyState === WebSocket.OPEN) {
console.log("Sent: DEBUG_RED");
socket.send("DEBUG_RED");
}
}
if (key === ' ') {
// 再生・一時停止トグル
// (vid.elt.paused はHTML Video要素のプロパティ)
if (vid.elt.paused) {
vid.loop(); // 再開
} else {
vid.pause(); // 停止
}
}
}
function windowResized() {
resizeCanvas(windowWidth, windowHeight);
}
2. M5側:M5StickC Plus2 (受信&再生)
これまでの「SDカード再生コード」に「UDP受信処理」を合体させました。
baseVideoTime = pcTime; と baseMillis = millis(); この2行が同期の核心です。
「PCから送られてきた動画の時刻」と、「それを受け取った瞬間のM5の起動時間」をセットで記録します。 これにより、「現在時刻 = 基準動画時刻 + (今のM5時間 – 受け取った時のM5時間)」 という計算で、通信がない間も正確な動画時間を推測し続けることができます。

※注意: コード内の WIFI_SSID と WIFI_PASS はご自身の環境に合わせて書き換えてください。
コード(step4_m5_sd_player.ino)
/**
* M5StickC Plus2 SD CSV Player + UDP Sync (Multi-CSV, Clean UI) [Robust Fixed-Length Reader]
*
* ■ 機能
* - SDカード上のCSV(固定長)を30fps相当でseek再生し、WS2812B(15LED)を点灯
* - UDPで受け取った「PC側の動画時刻(ms)」を基準に同期再生
* - BtnA: 現在のCSVを 0ms に頭出し (= 同期基準をリセット)
* - BtnB: 次のCSVへ切替 → 0msへ頭出しして再生
*
* ■ UDP受信内容
* - "DEBUG_RED" を受け取ると、デバッグでLED全赤のON/OFF切替(再生停止)
* - それ以外は "12345" のような数値文字列を「基準動画時刻(ms)」として解釈
*
* ■ 重要
* - CSVは「行の長さが固定」である必要があります(LINE_LENと一致)
* - RGB値は 000〜255 の3桁ゼロ埋め前提(例: 005, 120, 255)
*/
// ==================================================
// include
// ==================================================
#include <M5Unified.h>
#include <Adafruit_NeoPixel.h>
#include <SD.h>
#include <SPI.h>
#include <WiFi.h>
#include <WiFiUdp.h>
// ==================================================
// 設定(ここだけ触ればOK)
// ==================================================
// ---------- Wi-Fi / UDP ----------
const char* WIFI_SSID = "TP-Link_A438";
const char* WIFI_PASS = "38283789";
const int LOCAL_PORT = 8000;
WiFiUDP udp;
char packetBuffer[64];
// ---------- LED ----------
#define LED_PIN 32
#define LED_COUNT 15
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
// ---------- SD / SPI (HSPIをSD専用) ----------
const int SD_SCK = 0;
const int SD_MISO = 36;
const int SD_MOSI = 26;
const int SD_CS = 33;
SPIClass SPI_SD(HSPI);
File csvFile;
// ---------- CSVリスト ----------
const char* CSV_LIST[] = {
"/clip0.csv",
"/clip.csv",
"/clip1.csv",
"/clip2.csv",
"/clip3.csv",
"/clip4.csv"
};
const int CSV_COUNT = sizeof(CSV_LIST) / sizeof(CSV_LIST[0]);
int currentCsvIndex = 0;
// ---------- CSV固定長 / 再生設定 ----------
// ★重要:配列サイズに使うので define 推奨
// Windows改行(\r\n)などでズレるなら 191 に変更
#define LINE_LEN 190
// 30fps想定:1フレームのms
const float FRAME_DURATION = 33.333f;
// 固定長読み取り用バッファ(終端\0込み)
char lineBuf[LINE_LEN + 1];
// ==================================================
// 同期用(UDP基準点)
// ==================================================
uint32_t baseVideoTime = 0; // 受信した基準動画時刻(ms)
uint32_t baseMillis = 0; // その瞬間のmillis()
// ==================================================
// 状態管理
// ==================================================
bool isDebugRed = false;
uint32_t lastUiMs = 0;
// ==================================================
// UI描画領域
// ==================================================
const int UI_X = 0;
const int UI_W = 240;
const int HEADER_Y = 0;
const int HEADER_H = 40;
const int BODY_Y = 40;
const int BODY_H = 96;
// ==================================================
// utility
// ==================================================
static inline int clamp255(int v) {
if (v < 0) return 0;
if (v > 255) return 255;
return v;
}
// "000"〜"255" の3桁ASCIIを int に(ゼロ埋め前提)
static inline int parse3(const char* p) {
// '0'〜'9' 以外が来たら壊れてるので0扱い(安全側)
if (p[0] < '0' || p[0] > '9') return 0;
if (p[1] < '0' || p[1] > '9') return 0;
if (p[2] < '0' || p[2] > '9') return 0;
return (p[0]-'0')*100 + (p[1]-'0')*10 + (p[2]-'0');
}
// 現在の動画時刻(ms)
uint32_t getCurrentVideoTime() {
uint32_t elapsed = millis() - baseMillis;
return baseVideoTime + elapsed;
}
// ==================================================
// UI
// ==================================================
void drawHeader() {
M5.Display.fillRect(UI_X, HEADER_Y, UI_W, HEADER_H, TFT_BLACK);
M5.Display.setCursor(0, 0);
M5.Display.setTextColor(TFT_WHITE);
M5.Display.println("UDP Sync SD Player");
M5.Display.setCursor(0, 12);
M5.Display.printf("File: %s", CSV_LIST[currentCsvIndex]);
M5.Display.setCursor(0, 24);
M5.Display.println("A:Reset B:Next UDP:Sync");
}
void drawBody() {
M5.Display.fillRect(UI_X, BODY_Y, UI_W, BODY_H, TFT_BLACK);
uint32_t t = getCurrentVideoTime();
uint32_t elapsed = millis() - baseMillis;
M5.Display.setCursor(0, BODY_Y + 0);
M5.Display.setTextColor(TFT_CYAN);
M5.Display.printf("Time : %lu ms", t);
M5.Display.setCursor(0, BODY_Y + 12);
M5.Display.setTextColor(TFT_YELLOW);
M5.Display.printf("BaseV: %lu elap:%lu", baseVideoTime, elapsed);
M5.Display.setCursor(0, BODY_Y + 24);
M5.Display.setTextColor(TFT_YELLOW);
M5.Display.printf("BaseM: %lu ms:%lu", baseMillis, (uint32_t)millis());
M5.Display.setCursor(0, BODY_Y + 36);
M5.Display.setTextColor(TFT_GREEN);
M5.Display.printf("IP : %s", WiFi.localIP().toString().c_str());
M5.Display.setCursor(0, BODY_Y + 48);
M5.Display.setTextColor(TFT_WHITE);
M5.Display.printf("RSSI : %d dBm", WiFi.RSSI());
M5.Display.setCursor(0, BODY_Y + 60);
if (isDebugRed) {
M5.Display.setTextColor(TFT_RED);
M5.Display.print("DEBUG_RED: ON (play stop)");
} else {
M5.Display.setTextColor(TFT_WHITE);
M5.Display.print("DEBUG_RED: OFF");
}
// デバッグ用:LINE_LEN表示(原因切り分けに便利)
M5.Display.setCursor(0, BODY_Y + 72);
M5.Display.setTextColor(TFT_DARKGREY);
M5.Display.printf("LINE_LEN=%d", LINE_LEN);
}
// ==================================================
// CSV open
// ==================================================
bool openCsvByIndex(int idx) {
if (csvFile) csvFile.close();
const char* path = CSV_LIST[idx];
if (!SD.exists(path)) {
M5.Display.fillRect(UI_X, HEADER_Y, UI_W, HEADER_H, TFT_BLACK);
M5.Display.setCursor(0, 0);
M5.Display.setTextColor(TFT_RED);
M5.Display.println("Missing file:");
M5.Display.setCursor(0, 12);
M5.Display.println(path);
M5.Display.setTextColor(TFT_WHITE);
return false;
}
csvFile = SD.open(path, FILE_READ);
if (!csvFile) {
M5.Display.fillRect(UI_X, HEADER_Y, UI_W, HEADER_H, TFT_BLACK);
M5.Display.setCursor(0, 0);
M5.Display.setTextColor(TFT_RED);
M5.Display.println("Open failed:");
M5.Display.setCursor(0, 12);
M5.Display.println(path);
M5.Display.setTextColor(TFT_WHITE);
return false;
}
// ファイル切替時は0ms基準へ
baseVideoTime = 0;
baseMillis = millis();
isDebugRed = false;
drawHeader();
drawBody();
return true;
}
// ==================================================
// UDP
// ==================================================
void handleUdp() {
int packetSize = udp.parsePacket();
if (!packetSize) return;
int len = udp.read(packetBuffer, 63);
if (len <= 0) return;
packetBuffer[len] = 0;
// 前後の空白や改行を落とす("123\n" で死なないように)
String msg = String(packetBuffer);
msg.trim();
if (msg.indexOf("DEBUG_RED") >= 0) {
isDebugRed = !isDebugRed;
if (isDebugRed) {
for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, strip.Color(255, 0, 0));
strip.show();
}
return;
}
// 数値時刻として解釈
// msgが "0" or 数値>0 を有効
uint32_t pcTime = (uint32_t) strtoul(msg.c_str(), NULL, 10);
if (pcTime > 0 || msg.equals("0")) {
baseVideoTime = pcTime;
baseMillis = millis();
}
}
// ==================================================
// CSV parse & LED show (fixed length)
// ==================================================
void parseAndShowFixed(const char* line) {
// 形式:先頭 9桁時刻 + ',' を想定
// 例: "000012345,255,000,..." ではなく
// "000012345,255,000,..." でも可だが、ここは「3桁固定」前提
// 先頭チェック(壊れた行で暴走しない)
if (line[9] != ',') return;
int idx = 10; // 時刻9桁 + ',' の次
for (int i = 0; i < LED_COUNT; i++) {
int r = parse3(&line[idx]); idx += 4; // "RRR,"
int g = parse3(&line[idx]); idx += 4; // "GGG,"
int b = parse3(&line[idx]); idx += 4; // "BBB,"
r = clamp255(r);
g = clamp255(g);
b = clamp255(b);
strip.setPixelColor(i, strip.gamma32(strip.Color(r, g, b)));
}
strip.show();
}
// ==================================================
// play
// ==================================================
void playFromCsv() {
if (isDebugRed) return;
if (!csvFile) return;
uint32_t currentVideoTime = getCurrentVideoTime();
long targetLine = (long)(currentVideoTime / FRAME_DURATION);
long seekPos = targetLine * (long)LINE_LEN;
long sz = (long)csvFile.size();
// 行全部読める位置か
if (seekPos + (long)LINE_LEN <= sz) {
csvFile.seek(seekPos);
int n = csvFile.readBytes(lineBuf, LINE_LEN);
if (n != LINE_LEN) {
// SD読み取りが不安定なときの保険:頭出しして再試行
baseVideoTime = 0;
baseMillis = millis();
return;
}
lineBuf[LINE_LEN] = '\0';
// 末尾が '\r' なら落とす(Windows改行対策)
if (LINE_LEN >= 1 && lineBuf[LINE_LEN - 1] == '\r') {
lineBuf[LINE_LEN - 1] = '\0';
}
parseAndShowFixed(lineBuf);
} else {
// 末尾到達 → ループ
baseVideoTime = 0;
baseMillis = millis();
}
}
// ==================================================
// setup
// ==================================================
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
M5.Display.setRotation(1);
M5.Display.setTextSize(1);
M5.Display.fillScreen(TFT_BLACK);
strip.begin();
strip.setBrightness(10);
strip.show();
// SD用SPI
SPI_SD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
// SDマウント
if (!SD.begin(SD_CS, SPI_SD, 15000000)) {
M5.Display.setTextColor(TFT_RED);
M5.Display.setCursor(0, 0);
M5.Display.println("SD Init Fail!");
while (1) { delay(100); }
}
// 初期CSV
if (!openCsvByIndex(currentCsvIndex)) {
while (1) { delay(100); }
}
// Wi-Fi接続
M5.Display.setCursor(0, 120);
M5.Display.setTextColor(TFT_WHITE);
M5.Display.print("WiFi connecting");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(300);
M5.Display.print(".");
}
// UDP開始
udp.begin(LOCAL_PORT);
// UI
M5.Display.fillScreen(TFT_BLACK);
drawHeader();
drawBody();
// 同期基準(0ms)
baseVideoTime = 0;
baseMillis = millis();
}
// ==================================================
// loop
// ==================================================
void loop() {
M5.update();
// 1) UDP
handleUdp();
// 2) Buttons
if (M5.BtnA.wasPressed()) {
baseVideoTime = 0;
baseMillis = millis();
isDebugRed = false;
drawBody();
}
if (M5.BtnB.wasPressed()) {
int nextIdx = (currentCsvIndex + 1) % CSV_COUNT;
bool opened = false;
for (int tries = 0; tries < CSV_COUNT; tries++) {
currentCsvIndex = nextIdx;
if (openCsvByIndex(currentCsvIndex)) {
opened = true;
break;
}
nextIdx = (nextIdx + 1) % CSV_COUNT;
}
if (!opened) {
isDebugRed = true;
for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, strip.Color(255, 0, 0));
strip.show();
}
}
// 3) Play
playFromCsv();
// 4) UI update
if (millis() - lastUiMs >= 1000) {
lastUiMs = millis();
drawHeader();
drawBody();
}
}
使い方・動作確認
すべての準備が整ったら、以下の手順で動作を確認しましょう。
- M5StickCの起動:
- Arduinoにコードを書き込みます。
- Wi-Fiに接続されると、画面にIPアドレスが表示され、LEDが光り始めます(この時点ではまだPCと同期しておらず、自律再生しています)。
- Bridgeの起動:
- VSCodeのターミナルで
node bridge.jsを実行します(前回の講義参照)。← 忘れないように- ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)
- ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)
- VSCodeのターミナルで
- p5.jsの起動:
- ブラウザで
index.htmlを開きます。動画が自動再生されます。- ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)
- ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)
- ブラウザで
- 同期チェック:
- 接続確認: p5.jsの画面上でキーボードの 「c」キー を押してください。UDP通信が成功していれば、M5StickCのLEDが一瞬「赤色」に点灯します。
- シーク確認: p5.js画面下部の赤いシークバーをクリックして、動画時間を飛ばしてください。M5StickCのLEDも一瞬でそのシーンの光り方に切り替われば、完全同期成功です!
- 接続確認: p5.jsの画面上でキーボードの 「c」キー を押してください。UDP通信が成功していれば、M5StickCのLEDが一瞬「赤色」に点灯します。
