はじめに:値を見たいだけなのに、IDE を立ち上げている

センサの値がちゃんと出ているか確かめたい。やりたいのはそれだけなのに、Arduino IDE を起動して、スケッチを開いて、ポートを選んで、ようやくシリアルモニタにたどり着く。

これが地味に効いてくる場面がいくつかあります。

  • IDE が別のスケッチを開いている。確認のためだけにプロジェクトを切り替えたくない
  • 出先の PC に環境が無い。数値を見たいだけなのに IDE をインストールするのは大げさ
  • 人に見せたい。「この基板、こういう波形が出るんですよ」を相手の PC で再現してもらうのが面倒

そこで、ブラウザでページを開くだけで使えるシリアルモニタ/プロッタを作りました。インストールも拡張機能も要りません。

/tools/serial-monitor/ を開いて「接続」を押し、ポートを選ぶ。それだけで受信した行が流れ、数値ならリアルタイムでグラフになります。

ブラウザ上で3系列の折れ線グラフがリアルタイムに描画されているシリアルモニタの画面

デモモードで動かしたところ。上がプロッタ、下が受信テキスト。実機が無くてもここまで動きます

📝 この記事でわかること:

  • Web Serial API とは何で、ブラウザがどうやって安全にハードへ触れているのか
  • 作ったツールの使い方(配線不要のサンプルスケッチつき)
  • while (!Serial) が返ってこなくなる DTR の罠と、ESP32 で RTS を触ってはいけない理由
  • 外部ライブラリなしで、canvas に自動スケールのグラフを描く実装

🌐 Web Serial API とは

ブラウザから PC につながったシリアルポートを直接読み書きするための Web 標準 API です。navigator.serial として生えていて、ポートを開いて ReadableStream / WritableStream として扱えます。

長いあいだ、ブラウザからシリアルを触るには専用アプリや拡張機能が要りました。「Web ページが勝手に USB 機器へ話しかけられる」のは危ないからです。Web Serial API はそこを、ユーザーが明示的に選んだポートだけを渡すという設計で解いています。

3つの安全装置

// これは動かない(ページを開いた瞬間に呼ぶ)
const port = await navigator.serial.requestPort();

上のコードは、ページ読み込み時に実行しても失敗します。仕様が3つの条件を課しているためです。

① ユーザーの操作が要る。 仕様は requestPort() について「the site must have transient activation from something like the user clicking a button」と書いていて、transient activation(一時的なユーザー操作)が無ければ SecurityError で拒否されます。つまりクリックの延長線上でしか呼べないので、ページを開いただけで裏側でポートを漁ることはできません。

② https か localhost でしか動かない。 secure context 限定の API です。http:// で配信されたページからは navigator.serial 自体が見えません(http://localhost は例外として secure context 扱いなので、ローカル確認はできます)。

③ 選ばれたポートしか見えない。 ブラウザが出すダイアログでユーザーが選んだポートだけが、そのサイトに渡されます。サイト側は接続機器の一覧を取得できません。この仕様のおかげで、ポート一覧そのものを指紋(フィンガープリント)に使うこともできなくなっています。

💡 ワード解説:USB CDC とシリアルポート

Arduino をつないだときに現れる COM5 や /dev/ttyACM0 は、物理的な RS-232C の口ではなく USB CDC(Communications Device Class) という規格で「シリアルポートのふり」をしている仮想的な口です。

このふりの仕方には2通りあります。ブリッジ IC 方式(CH340・CP2102 などが USB とマイコンの UART を橋渡しする。Arduino UNO R3 や多くの ESP32 開発ボード)と、ネイティブ USB 方式(マイコン自身が USB をしゃべる。Leonardo・Micro・UNO R4・ESP32-S3 など)です。この違いが、後述する DTR の話にそのまま効いてきます。

対応ブラウザ

長く Chromium 系だけの機能でしたが、2026年5月19日リリースの Firefox 151 で Firefox(デスクトップ)も対応しました。10年以上前から要望が出ていた機能で、ようやく主要ブラウザ2系統で使えるようになったことになります。

ブラウザ 対応 備考
Chrome / Edge / Opera(デスクトップ) 89 以降 そのまま使える
Firefox(デスクトップ) 151 以降 サイトごとの許可アドオンを入れる手順が挟まる
Chrome(Android) 138 以降・限定的 Bluetooth RFCOMM で提供されるポートのみ
Firefox(Android)/ Safari / iOS 非対応 —

Firefox の実装は、Web MIDI と同じ アドオンゲーティングという方式をとっています。Mozilla の説明では「Compared to other web permission prompts, this gives the user a more detailed explanation of what they’re allowing」とのことで、通常の許可ダイアログより一段詳しい説明を挟む狙いです。なお Firefox Enterprise Policies を使う組織では既定で無効(DefaultSerialGuardSetting で管理者が制御)なので、会社の PC では動かないことがあります。

対応していないブラウザで開いても、ページが壊れることはありません。接続ボタンが無効になって案内が出るだけで、疑似データを流すデモモードはそのまま動きます。

Web Serial 非対応ブラウザで開いたときに表示される案内と、無効化された接続ボタン

非対応ブラウザで開いたところ。接続だけが無効になり、デモモードは残る

🔌 使い方:まずデモ、次に実機

ステップ1:ハード無しで動きを確かめる

/tools/serial-monitor/ を開いて「デモ開始」を押すと、疑似データ(正弦波・低速波・のこぎり波の3系列)が流れ始めます。

これは表示用のアニメーションではなく、実際の受信と同じ経路にデータを流し込んでいます。行の切り分けもパースもグラフ描画も本番と同じコードを通るので、一時停止・クリア・CSV 保存の挙動をここで一通り試せます。ボードを机に出す前に、使い勝手だけ確かめたいときにどうぞ。

ステップ2:ボードをつなぐ

  1. USB でボードをつなぐ(Arduino IDE のシリアルモニタは閉じておく。ポートは1つのアプリしか開けません)
  2. ボーレートをスケッチの Serial.begin() に合わせる(既定は 115200)
  3. 「接続」を押してポートを選ぶ

配線が一切要らない確認用スケッチを置いておきます。のこぎり波と正弦波を出すだけのものです。

// 配線不要。のこぎり波と正弦波の2系列を出す
void setup() {
  Serial.begin(115200);
}

void loop() {
  float t = millis() / 1000.0f;
  Serial.print("saw:");
  Serial.print(fmodf(t, 4.0f) / 4.0f * 5.0f, 3);
  Serial.print(",sine:");
  Serial.println(2.5f + 2.0f * sinf(2 * PI * 0.5f * t), 3);
  delay(20);
}
ESP32-S3 開発ボードから受信した saw と sine の2系列が折れ線グラフになり、下のコンソールに受信行が流れているシリアルモニタの画面

上のスケッチを ESP32-S3 開発ボードで動かしてつないだところ。凡例に saw と sine の名前と最新値が並び、下には受信した行がそのまま流れる

ステップ3:データの形を決める

改行区切りで1行に数値を並べるだけです。区切りはカンマ・タブ・スペースのどれでも構いません。

1.23,4.56,7.89

名前:値 の形にすると、凡例が CH1 ではなく実際の名前になります。Arduino IDE のシリアルプロッタと同じ流儀なので、既存のスケッチをそのまま持ってこられます。

temp:23.5,hum:40.2

上のサンプルスケッチが saw: / sine: を付けているのはこのためで、グラフの凡例に saw と sine が並びます。

数値として読めない行は無視されます。 READY や Setup done のようなデバッグ用メッセージが混ざっても、コンソールに出るだけでグラフは壊れません。

受信した行がどこを通ってグラフになるのか、流れはこうなっています。

flowchart TD A["Arduino / ESP32
Serial.println で1行送る"] --> U["USB シリアル
ネイティブ USB かブリッジIC"] U --> W["Web Serial API
port.readable"] W --> D["TextDecoderStream
バイト列を文字列に"] D --> L["行に切り分ける
CR / LF / CRLF"] L --> P{"数値として読めるか"} P -->|読める| G["canvas に折れ線を描く
CSV 用にも溜める"] P -->|読めない| C["コンソールに出すだけ"]

手元に残したいときは CSV

「CSV 保存」を押すと elapsed_ms と系列ごとの列を持つファイルが落ちてきます。先頭に UTF-8 の BOM を付けてあるので、Excel でダブルクリックしても文字化けしません。BOM 無しの UTF-8 CSV を Excel が Shift_JIS だと思い込む問題は、この3バイトを足すだけで消えます。

⚡ while (!Serial) が返ってこない

Arduino のスケッチにこう書く流儀があります。

void setup() {
  Serial.begin(115200);
  while (!Serial) { ; }   // シリアルがつながるまで待つ
  Serial.println("READY");
}

Arduino の公式リファレンスは if (Serial) について「indicates if the specified Serial port is ready」と説明していて、ネイティブ USB のボードでは「USB CDC serial connection is open」かどうかを返すとしています。起動直後のログを取りこぼしたくないときの定番です。

ところが、Web Serial から接続するとこれが返ってきません。 ボードは黙ったままで、ブラウザ側には1バイトも流れてこない。ボーレートも配線も合っているのに、です。

犯人は DTR

原因は、「ホストがポートを開いた」をボードがどう知るかにあります。答えは DTR(Data Terminal Ready)という制御線で、Arduino のコアはこれを見ています。

Arduino AVR コア(Leonardo・Micro)の CDC.cpp には、そのものずばりのコメントが書かれています。

// We check DTR state to determine if host port is open (bit 0 of lineState)
Serial_::operator bool() {
  bool result = false;
  if (_usbLineInfo.lineState > 0)
    result = true;
  delay(10);
  return result;
}

UNO R4 の Renesas コアも同じで、operator bool() は最終的に TinyUSB の tud_cdc_connected() に行き着きます。TinyUSB 側のコードには「DTR (bit 0) active is considered as connected」と書かれています。

一方、Web Serial の open() は DTR について何も約束していません。仕様の open() の手順にシグナルの記述は無く、制御線は「Serial ports include a number of additional signals for device detection and flow control which can be queried and set explicitly」——つまり setSignals() で明示的に触るもの、という位置づけです。

従来のシリアル端末ソフトでこの問題が表に出ないのは、ポートを開いた時点で DTR も一緒に上がるからです。Espressif のドキュメントも「certain operating systems or drivers activate these signals upon serial port opening」と、OS やドライバが勝手に上げてくる前提で書かれています。Web Serial は明示的に上げるまで上がらないので、ボードは「まだ誰も開いていない」と思い続けるわけです。

sequenceDiagram participant B as ブラウザ participant A as ボード(USB CDC) B->>A: port.open({ baudRate: 115200 }) Note over A: DTR は下がったまま
「まだ誰も開いていない」 A-->>A: while (!Serial) で待ち続ける B->>A: setSignals({ dataTerminalReady: true }) Note over A: DTR が上がった
=ホストが開いた A->>B: Serial.println("saw:1.234,sine:2.5")

直し方は1行です。

await port.open({ baudRate: 115200 });
// これが無いと while (!Serial) を使うスケッチが永久に待つ
if (port.setSignals) {
  await port.setSignals({ dataTerminalReady: true });
}

setSignals を持たない実装でも落ちないよう、存在チェックと try で囲んであります。

なぜ RTS には触らないのか

DTR と RTS はセットで語られることが多い制御線です。それでもこのツールは RTS を一切触っていません。ESP32 では、RTS がリセット線につながっているからです。

Espressif のドキュメントによれば、開発ボードの自動書き込み回路は EN(リセット)に RTS、GPIO0 に DTR をつないだ2石トランジスタ構成になっています。狙いは「片方だけが上がったときにリセットする」ことで、ドキュメントは「if both RTS and DTR are asserted together, this doesn’t reset the chip」と明記しています。

つまり RTS を上げると チップがリセットされ、場合によっては書き込みモードに落ちます。ブラウザから接続した瞬間にボードが再起動したら、シリアルモニタとして使い物になりません。

一方、DTR だけを上げるぶんには EN に触れず、動くのは GPIO0 側だけです。GPIO0 は起動時に読まれるストラッピングピンなので、すでに走っているチップがそれで止まることはありません。ただし GPIO0 を入力(BOOT ボタンなど)として使っているスケッチでは押しっぱなしに見えるので、そこだけ頭の片隅に置いておいてください。

内蔵 USB(USB-Serial-JTAG)を使う ESP32-S3 / C3 / C6 / P4 でも事情は同じです。esptool の該当コードには「The internal USB-Serial-JTAG bridge … reacts to a different pulse train than an external UART bridge」とあり、DTR と RTS の並びのパターンそのものがブートローダ突入の合図として使われています。触らないのがいちばん安全、という判断です。

⚠️ ESP32-S3 を USB-OTG モードで使っている場合

ESP32-S3 の USB モードには「Hardware CDC and JTAG」(既定)と「USB-OTG (TinyUSB)」があり、while (!Serial) の判定方法が違います。

arduino-esp32 の USBCDC.cpp を読むと、TinyUSB 側の接続判定は dtr && rts の両方が立ったときに成立します。DTR だけでは抜けられない計算になるので、USB-OTG モードのスケッチで待ち合わせが必要なら、while (!Serial) ではなく delay(2000) などで代替するほうが確実です。

既定の Hardware CDC(USB Serial/JTAG)側は HWCDC.cpp を見るかぎり DTR ではなく USB のフレーム検出などで接続を判定しているので、この話は関係ありません。同じ ESP32-S3 でも、IDE のボード設定ひとつで挙動が変わるということです。

✅ ESP32-S3 開発ボードでつないだ結果

スイッチサイエンスの ESPr Developer S3 Type-C(ESP32-S3-WROOM-1-N16R8)に、ステップ2 の確認用スケッチを書き込み、Windows の Chrome / Edge からつないだ結果です。このボードの Type-C は USB シリアル変換 IC の FTDI FT231XS(VID 0403 / PID 6015)につながっていて、ESP32-S3 のネイティブ USB ではなくブリッジ IC 方式です。esptool の書き込みが DTR / RTS の自動リセットで通るので、上で触れた自動書き込み回路も載っています。

確かめたこと 結果
接続とグラフ つながり、saw / sine の2系列がグラフと凡例(名前と最新値)に出た
接続時の再起動 起きなかった
一時停止・クリア・CSV 保存 実機のデータでもデモと同じように動いた

再起動しなかったことは、グラフの描き始めから読み取れます。このスケッチは millis() で波を作っているので、接続のたびにリセットされていれば、毎回ほぼ同じ位置(saw が 0 付近・sine が 2.5 付近)から描き始めるはずです。実際は切断と再接続を繰り返すたびに描き始めの位置がまちまちで、ボードは走り続けたまま、その続きを受け取っていました。

自動リセット回路があるのに再起動しなかったのは、このツールが DTR だけを上げて RTS に触らないので、EN(リセット)側が動かなかったためとみられます。仮にドライバがポートを開いた時点で DTR と RTS を同時に立てていたとしても、上で引いた Espressif の説明どおり、その組み合わせではリセットはかかりません。

UNO R4 Minima と、ESP32-S3 のネイティブ USB(Hardware CDC and JTAG/USB-OTG)での接続はまだ試していません。while (!Serial) の振る舞いが効いてくるのはこちらの構成なので、試したらこの記事に書き足します。

📈 canvas 直描きで軽量プロッタを作る

グラフのライブラリは山ほどありますが、今回は使いませんでした。このページのためだけに数百 KB を読み込ませたくなかったのと、やりたいことが「等間隔に並んだ数値を折れ線で描く」だけだったからです。結果、依存ゼロの JavaScript ひとつで済んでいます。

縦軸の自動スケールは、わざと鈍らせる

表示窓(200 / 500 / 1000 / 2000 点から選べます)の中の最小値と最大値を取り、上下に8%の余白を足す。ここまでは素直です。

問題は、その範囲を毎フレームそのまま採用すると縦軸がガタガタ暴れることでした。ノイズで最大値が一瞬跳ねるたびにグラフ全体が縮み、値を読むどころではありません。

そこで、目標値へ25%ずつ寄せていく形にしました。

if (this.yLo === null || this.paused) {
  this.yLo = lo; this.yHi = hi;      // 初回と一時停止中はそのまま
} else {
  this.yLo += (lo - this.yLo) * 0.25;  // 目標へ25%だけ近づく
  this.yHi += (hi - this.yHi) * 0.25;
}

たった2行ですが、体感はまるで違います。スパイクが出ても軸はゆっくり追いかけ、数フレームで落ち着く。「見やすさ」がフィルタ1つで決まるのは、電子工作の平滑化とまったく同じ感覚でした。

目盛りは「きりのいい数」に丸める

自動スケールをそのまま目盛りにすると 1.8374 のような値がラベルに並びます。読めません。人間が読める軸にするには、刻み幅を 1・2・5・10 × 10ⁿ のどれかに丸める必要があります。

function niceStep(range, targetTicks) {
  if (!(range > 0)) { return 1; }
  var raw = range / targetTicks;
  var mag = Math.pow(10, Math.floor(Math.log(raw) / Math.LN10 + 1e-9));
  var norm = raw / mag;
  var step;
  if (norm <= 1) { step = 1; }
  else if (norm <= 2) { step = 2; }
  else if (norm <= 5) { step = 5; }
  else { step = 10; }
  return step * mag;
}

末尾の + 1e-9 は誤植ではありません。Math.log(1000) / Math.LN10 は 2.9999999999999996 になるので、そのまま Math.floor() すると桁を1つ取り違えます。同じ罠に計算ツールの記事でも引っかかっていて、二進浮動小数点と10の冪の相性の悪さは何度でも顔を出します。

刻みが決まったらラベルの文字幅を ctx.measureText() で実測し、その分だけ左の余白を広げます。値が 0.5 のときと -1234.5 のときで軸の位置が変わるのは、これをやっているからです。

ダークモードは CSS に任せる

このサイトにはダークモードがあります。canvas は自分で色を塗る世界なので、放っておくと背景だけ黒くなって線は明るいテーマのままという残念な絵になります。

色を JavaScript のリテラルで持たない、というのが答えでした。CSS 変数として定義しておき、描画のたびに getComputedStyle() で読み出します。

var cs = window.getComputedStyle(this.root);
this.palette = {
  bg:   cs.getPropertyValue('--ewsm-plot-bg').trim(),
  grid: cs.getPropertyValue('--ewsm-plot-grid').trim(),
  series: [...]   // --ewsm-s0 〜 s7
};

こうすると、配色の管理はすべて CSS 側に寄ります。テーマの切り替えは body の class 付け替えで起きるので、MutationObserver でそれを監視して、変わったらパレットを捨てて描き直すだけです。

this.mo = new window.MutationObserver(function () {
  self.palette = null;      // 次の描画で読み直す
  self.paintLegend();
  self.requestDraw();
});
this.mo.observe(document.body, { attributes: true, attributeFilter: ['class'] });
同じシリアルプロッタをダークモードで表示した画面。背景と線の色がテーマに合わせて切り替わっている

ダークモード。線の色も目盛りも CSS 変数から読み直しているので、切り替えた瞬間に追従する

あとは devicePixelRatio を見て canvas の実ピクセル数を上げれば、高解像度ディスプレイでも線がぼけません。ここまで全部で、外部ライブラリは0個です。

🛠️ うまくいかないときは

症状 確認すること
ポート選択に何も出てこない 充電専用ケーブルではないか/ドライバ(CH340・CP2102 等)が入っているか
「接続できませんでした」と出る Arduino IDE など他のアプリがポートを掴んでいないか
接続はできるが何も流れない while (!Serial) を使っていないか(上の DTR の節)/ボーレートの不一致
文字化けする ボーレートの不一致/デバイスが Shift_JIS を出している(このツールは UTF-8 固定)
グラフが出ない println で改行を送っているか/数値以外の文字が混ざっていないか
途中でグラフの本数が増えた 行ごとに値の個数や名前が変わると、新しい系列として扱われます

✅ まとめ

  • ブラウザだけで動くシリアルモニタ/プロッタを作った。インストール不要で、/tools/serial-monitor/ を開けば使える
  • Web Serial API は「ユーザーの操作が要る・https 限定・選ばれたポートしか見えない」の3点で安全側に倒してある。Firefox 151(2026年5月)から Firefox デスクトップも対応した
  • while (!Serial) が返ってこないのは DTR が上がらないから。setSignals({ dataTerminalReady: true }) の1行で噛み合う
  • RTS は触らない。 ESP32 では RTS が EN(リセット)につながっていて、内蔵 USB でも DTR/RTS のパターンがブートローダ突入の合図になっている
  • グラフは canvas 直描きで足りる。自動スケールは25%ずつ寄せて鈍らせ、目盛りは 1・2・5 系列に丸め、色は CSS 変数から読む
  • 数値の丸めで Math.log(1000) / Math.LN10 が3にならない罠は、ツールを作るたびに出てくる

「実機を出す前にちょっと動きを見たい」というときは、デモモードだけでも触ってみてください。ボードが無くてもグラフは動きます。

関連記事