Back

Web Serial APIでできるクールなこと

Web Serial APIでできるクールなこと

Web Serial APIを使うと、ネイティブアプリやブラウザ拡張機能なしに、Webページからシリアル通信対応の物理デバイス(USB-シリアル変換ドングル、マイコン開発ボード、3Dプリンター、ペアリング済みのBluetooth Classicデバイスなど)への読み書き可能なバイトストリームを開くことができます。このAPIは、MediaDevices(カメラ・マイク)、WebUSB(生のUSBインターフェース)、WebHID(ゲームパッド・キーボード)と同じ系譜に属するブラウザのハードウェアアクセスAPIです。ページがアクセスをリクエストすると、ブラウザが権限ダイアログを表示し、ユーザーがデバイスを選択します。

週末プロジェクトを始める前に、サポート状況を把握しておく必要があります。MDNのブラウザ互換性テーブルによると、Web SerialはデスクトップのChrome 89以降、Edge 89以降、Opera 75以降、Firefox 151以降でサポートされており、Safariはサポートを発表していません。このAPIはセキュアコンテキスト(HTTPSまたはlocalhost)と、requestPort()を呼び出すためのユーザージェスチャーを必要とします。ページ読み込み時にポートのプロンプトを表示することはできません。本記事では、これらの制約の上に構築できる6つの具体的なプロジェクトカテゴリを紹介し、それぞれについてWeb Serial固有のコード、ハードウェアとの組み合わせ、そして最初にはまりやすい落とし穴を解説します。

重要なポイント

  • Web SerialはデスクトップのChrome 89以降、Edge 89以降、Opera 75以降、Firefox 151以降でサポートされており、Safariはサポートを発表していません。また、requestPort()を呼び出すにはセキュアコンテキストとユーザージェスチャーの両方が必要です。
  • OSのシリアルドライバーがWebUSBより先にUSB-シリアル変換アダプターを占有するため、Web SerialはWebUSBとは別に存在します。COMポートや/dev/ttyノードとして列挙されるデバイスにはWeb Serialが必要です。
  • 再接続時の「port already open」エラーを防ぐには、port.close()の前にreader.releaseLock()を呼び出してください。ストリームロックのリークが原因です。
  • Chrome 117以降、Web SerialはallowedBluetoothServiceClassIdsおよびbluetoothServiceClassIdフィルターを通じて、ペアリング済みのBluetooth Classic RFCOMM/SPPデバイスと通信できます。WebBluetoothの呼び出しは不要で、ワイヤレスシリアル通信が実現します。
  • Chrome 130ではSerialPort.connectedブール値が追加され、物理的に接続されているポートとアプリが閉じたポートを区別できるようになりました。

最小限の接続・読み取り・書き込み・クリーンアップパターン

すべてのWeb Serialプロジェクトは同じ4つのステップから始まります。ポートのリクエスト、ボーレートでの開放、ストリームを通じた読み書き、そしてロックを解放してから閉じるクリーンアップです。以下のコードスニペットは、この記事全体で参照するリファレンス実装です。オプションのfilters引数を使ってUSBベンダーIDとプロダクトIDでブラウザのポートピッカーを絞り込んでいます。WICG Web Serial仕様のSerialPortRequestOptions定義に従い、関係のないシリアルポートを除外することで、ユーザーには関連するデバイスのみが表示されます。

// ユーザージェスチャーハンドラー(クリックリスナーなど)内で実行する必要があります。
const port = await navigator.serial.requestPort({
  filters: [{ usbVendorId: 0x2341 }], // 省略可能。「任意のポート」の場合は省略
});
await port.open({ baudRate: 115200 });

const reader = port.readable.getReader();
try {
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;          // readerがキャンセルされた
    console.log(value);       // valueはUint8Arrayチャンク
  }
} finally {
  reader.releaseLock();       // closeの前に解放する
  await port.close();
}

port.close()の前にreader.releaseLock()を呼び出さないことは、再接続時の「port already open」エラーのよくある原因です。ロックはreadableストリームを保持しており、仕様のSerialPort.close()の手順に従い、ストリームがロックされている間はclose()が拒否されます。テキストプロトコルの場合は、生のUint8Arrayチャンクを手動でデコードする代わりに、TextEncoderStreamTextDecoderStreamを通じてストリームをパイプしてください。エンコードの詳細はMDNのWeb Serialガイドに記載されています。

Web Serial APIの活用例:週末プロジェクト6選

Web Serialが最も力を発揮するプロジェクトカテゴリは、読み取り重視のデバッグツール、書き込み重視のプロトコルクライアント、長時間稼働のストリーミングループ、リアルタイム可視化、ブラウザからのレンダリングによるディスプレイ制御、そしてデバイス設定UIの6つです。以下の各項目では、フルアプリケーションの解説ではなく、Web Serial固有のロジックに絞って説明します。

1. ブラウザベースのシリアルモニター

シリアルモニターは、Arduino IDEのSerial Monitorやscreen /dev/ttyUSB0をブラウザタブに置き換える、読み取り重視のデバッグツールです。ポートを開き、受信バイトをデコーダーに通してストリーミングし、デコードされた行をDOMに追記します。書き込みプロトコルの複雑さなしに読み取りループを実践できる、最もシンプルかつ最初に作るべきプロジェクトです。

ハードウェア/プロトコルの組み合わせ: 改行区切りのテキストを送信するUARTデバイス(Arduino、ESP32、USB-シリアル変換アダプターなど)

const port = await navigator.serial.requestPort();
await port.open({ baudRate: 9600 });

const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();

let buffer = "";
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  const lines = buffer.split("\n");
  buffer = lines.pop() ?? "";        // 末尾の不完全な行を保持
  lines.forEach(line => appendToLog(line));
}

落とし穴: シリアルデータは任意のチャンク境界で届くため、行全体が揃うとは限りません。1回のread()で半行分が返ることも、3行分が返ることもあります。区切り文字に達するまでバッファリングしてからレンダリングしてください。

2. ファームウェアフラッシャー

ファームウェアフラッシャーは、ブラウザ版esptoolがESP32チップに対して行うように、コンパイル済みバイナリをシリアル経由でマイコンのフラッシュメモリに書き込みます。書き込み重視でプロトコルに依存したツールです。ペイロードを送信する前に、チップをブートローダーモードに移行させ、チップのフラッシュプロトコルに従ってデータをフレーミングする必要があります。

ハードウェア/プロトコルの組み合わせ: SLIP形式のシリアルブートローダープロトコルを使用するESP32/ESP8266。エントリーシーケンスはDTRおよびRTS制御信号で制御します。

const port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });

// 制御信号を切り替えてESPをダウンロードモードに強制する。
// Espressifのドキュメントに記載されたブートモードシーケンスを参照。
await port.setSignals({ dataTerminalReady: false, requestToSend: true });
await port.setSignals({ dataTerminalReady: true, requestToSend: false });
// ...次にport.writableを通じてフレーム化されたブートローダーコマンドを送信

ESPToolスタイルのブラウザフラッシャーは、書き込みコマンドを送信する前に、特定のシーケンスでDTRおよびRTS制御信号を切り替えてチップをダウンロードモードに移行させる必要があります。Espressifはブートモードの信号動作をドキュメント化しており、Web SerialはSerialPort.setSignals()を通じてこの切り替えを公開しています。

落とし穴: DTR/RTSのブートローダーシーケンスをスキップすると、チップはシリアルブートローダーに入らずアプリケーションファームウェアのまま動作するため、フラッシュが正常に進みません。

3. G-codeジョブストリーマー

G-codeストリーマーは、3DプリンターやCNCマシンに印刷・切削ジョブを1行ずつ送信し、ファームウェアが各行を確認応答するのを待ってから次の行を送ります。フロー制御を伴う長時間稼働の書き込みループであり、単純な一方向書き込みよりも実装難易度が高いプロジェクトです。

ハードウェア/プロトコルの組み合わせ: USB シリアル経由のMarlinまたはGRBLファームウェア。ok確認応答を使った行ベースのG-codeをやり取りします。

const encoder = new TextEncoderStream();
encoder.readable.pipeTo(port.writable);
const writer = encoder.writable.getWriter();

const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();

for (const line of gcodeLines) {
  await writer.write(line + "\n");
  // ファームウェアが行を処理したことを報告するまでブロック。
  let ack = "";
  while (!ack.includes("ok")) {
    const { value } = await reader.read();
    ack += value;
  }
}

G-codeストリーマーは次の行を送る前にプリンターのok確認応答を待つ必要があります。RepRap G-codeリファレンスにこのハンドシェイクが定義されており、確認応答なしにシリアルバッファを溢れさせると、ジョブの途中でファームウェアのコマンドキューがオーバーランします。

落とし穴: okは温度レポートやその他の非同期行と混在して届く可能性があります。次に読み取った行が確認応答だと仮定せず、トークンに対してマッチングを行ってください。

4. リアルタイムテレメトリーダッシュボード

テレメトリーダッシュボードは、シリアルポートから連続するセンサーストリームを読み取り、ブラウザ内でリアルタイムチャートとして表示します(温度、電圧、加速度計の各軸、GPSの位置情報など)。Web Serialの処理は読み取りループのみで、デコードされた値を同じタブで動作するチャートライブラリに直接パイプできる点に価値があります。

ハードウェア/プロトコルの組み合わせ: CSV形式の行を出力するセンサー搭載マイコン、またはNMEA 0183センテンスを出力するGPSモジュール

const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();

let buffer = "";
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  let nl;
  while ((nl = buffer.indexOf("\n")) >= 0) {
    const [t, v] = buffer.slice(0, nl).split(",");
    pushSample(Number(t), Number(v));   // チャートにデータを渡す
    buffer = buffer.slice(nl + 1);
  }
}

落とし穴: 高速なセンサーはチャートの再描画速度より速く行を出力することがあります。毎回のread()でレンダリングするのではなく、サンプルをバッチ処理してrequestAnimationFrameで更新してください。そうしないとメインスレッドが停止します。

5. ディスプレイ・ビジュアルコントローラー

ディスプレイコントローラーは、ブラウザでピクセルやフレームをレンダリングし、LEDマトリクス、OLED、ePaperパネルなどの物理ディスプレイに送信します。ブラウザがレンダリングエンジンとして機能し、JavaScriptでビットマップや輝度配列を計算して、ディスプレイのコントローラーが期待するバイト列を書き込みます。

ハードウェア/プロトコルの組み合わせ: MAX7219駆動のLEDマトリクス、SSD1306 OLED、またはUART経由でフレームバッファバイトを受け取るマイコン経由のePaperモジュール

const writer = port.writable.getWriter();

// 8x8フレームを1行1バイトで表現(ビットが立っている = LED点灯)
function renderFrame(rows: number[]) {
  return writer.write(Uint8Array.from(rows));
}

await renderFrame([
  0b00111100, 0b01000010, 0b10100101, 0b10000001,
  0b10100101, 0b10011001, 0b01000010, 0b00111100,
]);

落とし穴: 書き込みはディスプレイのリフレッシュレートに自動的に同期されないため、ブラウザからアニメーションを送信するとティアリングやフレームドロップが発生することがあります。部分的な行をストリーミングするのではなく、更新ごとに完全なフレームを送信し、ディスプレイ固有のタイミング制御はマイコン側に任せてください。

6. デバイス設定UI

デバイス設定UIは、シリアル接続されたデバイスの設定を読み書きします。ドローンのフライトコントローラーのパラメーター、アマチュア無線機のチャンネルメモリー、IoTモジュールのレジスタ値などが対象です。このユースケースでこそVID/PIDフィルターパターンが真価を発揮します。設定アプリは特定のデバイスを対象とするため、ベンダーIDとプロダクトIDでポートピッカーを絞り込むことで、マシン上のすべてのCOMポートではなく、ユーザーのデバイスだけが表示されます。

ハードウェア/プロトコルの組み合わせ: MSPを話すフライトコントローラー、無線機のCAT/CI-Vプロトコル、またはドキュメント化されたレジスタアクセスコマンドセットを持つモジュール(requestPortフィルターで選択)

// ピッカーを特定のベンダー/プロダクトに絞り込む。
const port = await navigator.serial.requestPort({
  filters: [{ usbVendorId: 0x10c4, usbProductId: 0xea60 }], // CP210xの例
});
await port.open({ baudRate: 115200 });

const writer = port.writable.getWriter();
await writer.write(buildReadConfigCommand());   // 現在の設定をリクエスト
// ...レスポンスを読み取り、フォームフィールドに反映し、保存時に書き戻す

requestPort()filters: [{ usbVendorId, usbProductId }]を渡すと、SerialPortRequestOptions仕様に従い、ダイアログが一致するポートに絞り込まれます。再訪問時にダイアログをスキップするには、navigator.serial.getPorts()でユーザーがすでに承認済みのポートを取得できます。

落とし穴: ピッカーに表示されるベンダーIDとプロダクトIDは、接続先デバイスではなくUSB-シリアル変換チップ(FTDI、CP210x、CH340)のものです。そのため、無関係な複数の製品が同じusbVendorIdを共有することがあります。可能な限りusbVendorIdusbProductIdの両方でフィルタリングしてください。

Web Serialが適切なツールかどうかの判断基準

デバイスがCOMポートまたは/dev/ttyノードとして列挙される場合、つまりOSがすでにシリアルドライバーをロードしている場合は、Web Serialを使用してください。OSのシリアルドライバーがWebUSBより先にUSB-シリアル変換アダプターを占有するため、Web SerialはWebUSBとは別に存在します。この点はWICG Web Serial explainerに直接記載されています。他のデバイスクラスには別のAPIを使用してください。

APIデバイスの種類権限モデルブラウザサポート最適な用途
Web Serialシリアル/COM/ttyポート上のデバイスユーザージェスチャーピッカーChrome/Edge 89以降、Opera 75以降、Firefox 151以降マイコン、プリンター、USB-シリアルドングル
WebUSB生のUSBインターフェース(OSドライバーなし)ユーザージェスチャーピッカーChromiumベースシリアルドライバーのないカスタムUSBデバイス
WebHIDヒューマンインターフェースデバイスユーザージェスチャーピッカーChromiumベースゲームパッド、キーボード、カスタムHIDペリフェラル
Web BluetoothBluetooth Low Energy(GATT)ユーザージェスチャーピッカーChromiumベースBLEセンサー、ビーコン、ウェアラブル
WebSocket + バックエンドデーモンすべてサーバー経由全ブラウザクロスブラウザ対応、サーバーサイドパース

今すぐFirefoxとSafariのユーザーをサポートする必要があり、デバイスロジックをサーバーサイドに置ける場合は、WebSocketを公開する小さなネイティブデーモンがポータブルなフォールバックになります。ただし、Web Serialが不要とするインストール手順が必要になります。

知っておく価値のある最近の追加機能

Chrome 117でBluetooth Classic RFCOMM/SPPサポートがWeb Serialに追加され、Chrome 130では物理的に接続されているポートとアプリが閉じたポートを区別するためのSerialPort.connectedブール値が追加されました。どちらも既存のチュートリアルの多くには記載されていないため、活用する価値があります。

デスクトップのChrome 117以降、Web SerialはペアリングされたBluetooth Classic RFCOMM/SPPデバイスと通信できます。requestPort()ピッカーにカスタムRFCOMMサービスを表示するには、allowedBluetoothServiceClassIds(またはfilters: [{ bluetoothServiceClassId }]エントリー)を渡し、サービスクラスはport.getInfo().bluetoothServiceClassIdで読み取れます。WebBluetoothの呼び出しは不要です。このBluetooth経路はChromiumのみ(Chrome/Edge 117以降、Opera 103以降)であり、MDNではまだ実験的とマークされています。Firefox 151の一般的なWeb Serialサポートには、Bluetoothサービスクラスオプションはまだ含まれていません。詳細はChromeのSerial over Bluetooth on the webに記載されています。これにより、ワイヤレスシリアル通信がUSB向けに作成した読み書きストリームと同じ形で扱えるようになります。

Chrome 130ではSerialPort.connectedが追加されました。このブール値は、ポートが物理的に存在するが必ずしも開いていない場合にtrueになります。これにより、再接続UXで「デバイスが取り外された」と「アプリがポートを閉じた」を区別できます。connectイベントとdisconnectイベントと組み合わせることで、ポーリングなしにリアルタイムの接続インジケーターを実現できます。

requestPort()はユーザーがWeb Serialの権限ダイアログを閉じた際に静かに拒否されますが、多くの実装ではその拒否を可視状態として表示しないため、接続ボタンがデフォルト状態に戻るだけでページが何もしていないように見えます。このUX上の失敗は上記6つのプロジェクトカテゴリすべてに共通します。Web Serial接続フローのセッションリプレイでは、ユーザーが接続ボタンを2〜3回連続でクリックした後に離脱するという特徴的なパターンがよく見られます。これは拒否が伝わっていないサインです。requestPort()の拒否をキャッチして、「デバイスが選択されていません」や「ポートが使用中です」などの明示的なメッセージを表示してください。別のプロセス(Arduino IDE、screenModemManager)が保持しているポートも同様に静かに失敗します。

1つのカテゴリを選び、この記事の冒頭にある最小限の接続・読み取り・書き込み・クリーンアップブロックを実装し、週末を費やす前にサポートマトリクスが対象ユーザーに合っているか確認してください。動作するビルドへの最速ルートは、手元にあるボードに対するシリアルモニターです。読み取りループとクリーンアップが安定したら、残りの5つのカテゴリは同じバイトストリームのバリエーションに過ぎません。ただし、Safariユーザーを含む場合、デバイスがCOMまたは/dev/ttyノードとして列挙されない場合(WebUSB、WebHID、またはWeb Bluetoothを使用してください)、またはパースロジックが本質的にサーバーサイドに属する場合は、Web Serialは適切なツールではありません。そのような場合は、WebSocketを使った小さなネイティブデーモンがインストール手順のコストと引き換えにより広いリーチを提供します。

よくある質問

権限ダイアログを閉じると、requestPort()のPromiseはresolveではなくrejectされますが、多くの実装ではその拒否を可視状態として表示しないため、ボタンは静かにデフォルト状態に戻ります。requestPort()の呼び出しをtryまたはcatchでラップし、拒否時に「デバイスが選択されていません」などの明示的なメッセージを表示してください。Arduino IDE、screen、ModemManagerなど別のプロセスが保持しているポートも同様に静かに失敗します。

ArduinoにはWeb Serialを使用してください。OSがシリアルドライバーをロードしてUSB-シリアル変換アダプターを占有するため、ボードはCOMポートまたは/dev/ttyノードとして列挙され、WebUSBでは到達できません。WebUSBはOSのシリアルドライバーを持たない生のUSBインターフェース(カスタムUSBデバイスなど)向けです。デバイスがCOMポートとして表示される場合は、WebUSBではなくWeb Serialが必要です。

はい。navigator.serial.getPorts()を呼び出すと、ユーザーが以前のセッションで承認済みのポートの配列を取得でき、プロンプトなしに直接開くことができます。これにより、再訪問時のユーザージェスチャーピッカーを完全にスキップできます。Chrome 130以降では、SerialPort.connectedがポートの物理的な接続状態を示すブール値を返し、再接続UXでデバイスが取り外されたのかアプリが閉じたのかを区別できます。

Web SerialはMDNのブラウザ互換性データによると、デスクトップのChrome 89以降、Edge 89以降、Opera 75以降、Firefox 151以降で動作します。Safariはサポートを発表しておらず、公開されたロードマップもありません。このAPIはセキュアコンテキスト(HTTPSまたはlocalhost)とrequestPort()を呼び出すためのユーザージェスチャーも必要です。今すぐFirefoxとSafariの広範なカバレッジが必要な場合は、WebSocketを公開するネイティブデーモンがインストール手順のコストと引き換えにポータブルなフォールバックになります。

再接続時の「port already open」エラーは、ほぼ必ずストリームロックのリークが原因です。readerがreadableストリームを保持したままport.close()を呼び出すと、ストリームがロックされているためcloseが拒否されます。読み取りループがどのように終了しても必ずロックが解放されるよう、finallyブロック内でreader.releaseLock()をport.close()の前に呼び出してください。port.writableから取得したwriterにも同様のことが当てはまります。

Gain control over your UX

See how users are using your site as if you were sitting next to them, learn and iterate faster with OpenReplay. — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay