シリアルポートに対して読み取り / 書き込みを行う

Web Serial API を使用すると、ウェブサイトからシリアル デバイスと通信できます。

François Beaufort
François Beaufort

シリアルポートは、バイト単位でデータを送受信できる双方向通信インターフェースです。

Web Serial API は、ウェブサイトが JavaScript を使用してシリアル デバイスに対して読み取りと書き込みを行うための手段を提供します。シリアル デバイスは、ユーザーのシステムのシリアルポート経由で接続するか、シリアルポートをエミュレートする取り外し可能な USB デバイスや Bluetooth デバイス経由で接続します。

つまり、Web Serial API は、ウェブサイトがマイクロコントローラや 3D プリンタなどのシリアル デバイスと通信できるようにすることで、ウェブと物理世界をつなぎます。

この API は WebUSB にも適しています。オペレーティング システムでは、アプリが低レベルの USB API ではなく、上位レベルのシリアル API を使用して一部のシリアルポートと通信することが求められるためです。

推奨されるユースケース

教育、趣味、産業の分野では、ユーザーは周辺機器デバイスをパソコンに接続します。これらのデバイスは、カスタム ソフトウェアで使用されるシリアル接続を介してマイクロコントローラによって制御されることがよくあります。これらのデバイスを制御するカスタム ソフトウェアの一部は、ウェブ技術で構築されています。

ユーザーが手動でインストールしたエージェント アプリを介してウェブサイトがデバイスと通信している場合があります。他のアプリは、Electron などのフレームワークを介してパッケージ化されたアプリとして提供されます。一方、コンパイル済みのアプリを USB フラッシュ ドライブ経由でデバイスにコピーするなど、追加の手順をユーザーが行う必要がある場合もあります。

いずれの場合も、ウェブサイトとそのウェブサイトが制御するデバイスとの間で直接通信することで、ユーザー エクスペリエンスが向上します。

現在のステータス

ステップ ステータス
1. 説明を作成する 完了
2. 仕様の最初の下書きを作成する 完了
3. フィードバックを収集してデザインを反復する 完了
4. オリジン トライアル 完了
5. リリース 完了

Web Serial API の使用

特徴検出

Web Serial API がサポートされているかどうかを確認するには、次のコマンドを使用します。

if ("serial" in navigator) {
  // The Web Serial API is supported.
}

シリアルポートを開く

Web Serial API は、設計上非同期です。これにより、ウェブサイトの UI が入力を待っているときにブロックされることを防ぎます。これは、シリアルデータをいつでも受信する可能性があり、それをリッスンする方法が必要になるため重要です。

シリアルポートを開くには、まず SerialPort オブジェクトにアクセスします。これを行うには、タップやマウスクリックなどのユーザー ジェスチャーに応答して navigator.serial.requestPort() を呼び出して、1 つのシリアルポートを選択するようユーザーにプロンプトを表示するか、navigator.serial.getPorts() から 1 つを選択します。navigator.serial.getPorts() は、ウェブサイトにアクセス権が付与されているシリアルポートのリストを返します。

document.querySelector('button').addEventListener('click', async () => {
  // Prompt user to select any serial port.
  const port = await navigator.serial.requestPort();
});
// Get all serial ports the user has previously granted the website access to.
const ports = await navigator.serial.getPorts();

navigator.serial.requestPort() 関数は、フィルタを定義するオプションのオブジェクト リテラルを受け取ります。これらは、USB 経由で接続されたシリアル デバイスを、必須の USB ベンダー(usbVendorId)とオプションの USB プロダクト ID(usbProductId)と照合するために使用されます。

// Filter on devices with the Arduino Uno USB Vendor/Product IDs.
const filters = [
  { usbVendorId: 0x2341, usbProductId: 0x0043 },
  { usbVendorId: 0x2341, usbProductId: 0x0001 }
];

// Prompt user to select an Arduino Uno device.
const port = await navigator.serial.requestPort({ filters });

const { usbProductId, usbVendorId } = port.getInfo();
ウェブサイトのシリアルポート プロンプトのスクリーンショット
BBC micro:bit を選択するためのユーザー プロンプト

requestPort() を呼び出すと、デバイスを選択するようユーザーに求めるメッセージが表示され、SerialPort オブジェクトが返されます。SerialPort オブジェクトを取得したら、目的のボーレートで port.open() を呼び出すと、シリアルポートが開きます。baudRate ディクショナリ メンバーは、シリアルラインを介してデータが送信される速度を指定します。これはビット/秒(bps)の単位で表されます。この値が正しく指定されていないと、送受信されるデータがすべて意味不明になるため、デバイスのドキュメントで正しい値を確認してください。シリアルポートをエミュレートする一部の USB デバイスと Bluetooth デバイスでは、この値はエミュレーションによって無視されるため、任意の値に安全に設定できます。

// Prompt user to select any serial port.
const port = await navigator.serial.requestPort();

// Wait for the serial port to open.
await port.open({ baudRate: 9600 });

シリアルポートを開く際に以下のオプションを指定することもできます。これらのオプションは省略可能で、便利なデフォルト値があります。

  • dataBits: フレームあたりのデータビット数(7 または 8)。
  • stopBits: フレームの末尾にあるストップビットの数(1 または 2)。
  • parity: パリティ モード("none""even"、または "odd")。
  • bufferSize: 作成する読み取りバッファと書き込みバッファのサイズ(16 MB 未満にする必要があります)。
  • flowControl: フロー制御モード("none" または "hardware")。

シリアルポートから読み取る

Web Serial API の入力ストリームと出力ストリームは、Streams API によって処理されます。

シリアルポート接続が確立されると、SerialPort オブジェクトの readable プロパティと writable プロパティは、ReadableStreamWritableStream を返します。これらは、シリアル デバイスとの間でデータを受信および送信するために使用されます。どちらも、データ転送に Uint8Array インスタンスを使用します。

シリアル デバイスから新しいデータが届くと、port.readable.getReader().read()valuedone の 2 つのプロパティを非同期で返します。done が true の場合、シリアルポートが閉じられたか、データの受信が停止しています。port.readable.getReader() を呼び出すと、読み取り専用が作成され、readable がロックされます。readableロックされている間は、シリアルポートを閉じることはできません。

const reader = port.readable.getReader();

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // Allow the serial port to be closed later.
    reader.releaseLock();
    break;
  }
  // value is a Uint8Array.
  console.log(value);
}

バッファ オーバーフロー、フレーミング エラー、パリティ エラーなどの特定の条件下では、致命的でないシリアルポート読み取りエラーが発生することがあります。これらは例外としてスローされ、port.readable をチェックするループに別のループを追加することでキャッチできます。これは、エラーが致命的でない限り、新しい ReadableStream が自動的に作成されるためです。シリアル デバイスの削除など、致命的なエラーが発生すると、port.readable は null になります。

while (port.readable) {
  const reader = port.readable.getReader();

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        // Allow the serial port to be closed later.
        reader.releaseLock();
        break;
      }
      if (value) {
        console.log(value);
      }
    }
  } catch (error) {
    // TODO: Handle non-fatal read error.
  }
}

シリアル デバイスからテキストが返された場合は、次のように port.readableTextDecoderStream にパイプできます。TextDecoderStream は、すべての Uint8Array チャンクを取得して文字列に変換する変換ストリームです。

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

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // Allow the serial port to be closed later.
    reader.releaseLock();
    break;
  }
  // value is a string.
  console.log(value);
}

「Bring Your Own Buffer」リーダーを使用すると、ストリームから読み取るときのメモリ割り当て方法を制御できます。port.readable.getReader({ mode: "byob" }) を呼び出して ReadableStreamBYOBReader インターフェースを取得し、read() を呼び出すときに独自の ArrayBuffer を指定します。Web Serial API は、Chrome 106 以降でこの機能をサポートしています。

try {
  const reader = port.readable.getReader({ mode: "byob" });
  // Call reader.read() to read data into a buffer...
} catch (error) {
  if (error instanceof TypeError) {
    // BYOB readers are not supported.
    // Fallback to port.readable.getReader()...
  }
}

value.buffer のバッファを再利用する方法の例を次に示します。

const bufferSize = 1024; // 1kB
let buffer = new ArrayBuffer(bufferSize);

// Set `bufferSize` on open() to at least the size of the buffer.
await port.open({ baudRate: 9600, bufferSize });

const reader = port.readable.getReader({ mode: "byob" });
while (true) {
  const { value, done } = await reader.read(new Uint8Array(buffer));
  if (done) {
    break;
  }
  buffer = value.buffer;
  // Handle `value`.
}

シリアルポートから特定の量のデータを読み取る方法の別の例を次に示します。

async function readInto(reader, buffer) {
  let offset = 0;
  while (offset < buffer.byteLength) {
    const { value, done } = await reader.read(
      new Uint8Array(buffer, offset)
    );
    if (done) {
      break;
    }
    buffer = value.buffer;
    offset += value.byteLength;
  }
  return buffer;
}

const reader = port.readable.getReader({ mode: "byob" });
let buffer = new ArrayBuffer(512);
// Read the first 512 bytes.
buffer = await readInto(reader, buffer);
// Then read the next 512 bytes.
buffer = await readInto(reader, buffer);

シリアルポートに書き込む

シリアル デバイスにデータを送信するには、データを port.writable.getWriter().write() に渡します。後でシリアルポートを閉じるには、port.writable.getWriter()releaseLock() を呼び出す必要があります。

const writer = port.writable.getWriter();

const data = new Uint8Array([104, 101, 108, 108, 111]); // hello
await writer.write(data);


// Allow the serial port to be closed later.
writer.releaseLock();

次に示すように、port.writable にパイプされた TextEncoderStream を介してデバイスにテキストを送信します。

const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

シリアルポートを閉じる

port.close() は、readable メンバーと writable メンバーがロック解除されている場合、シリアルポートを閉じます。つまり、それぞれのリーダーとライターに対して releaseLock() が呼び出されています。

await port.close();

ただし、ループを使用してシリアル デバイスから継続的にデータを読み取る場合、port.readable はエラーが発生するまで常にロックされます。この場合、reader.cancel() を呼び出すと、reader.read(){ value: undefined, done: true } ですぐに解決されるため、ループで reader.releaseLock() を呼び出すことができます。

// Without transform streams.

let keepReading = true;
let reader;

async function readUntilClosed() {
  while (port.readable && keepReading) {
    reader = port.readable.getReader();
    try {
      while (true) {
        const { value, done } = await reader.read();
        if (done) {
          // reader.cancel() has been called.
          break;
        }
        // value is a Uint8Array.
        console.log(value);
      }
    } catch (error) {
      // Handle error...
    } finally {
      // Allow the serial port to be closed later.
      reader.releaseLock();
    }
  }

  await port.close();
}

const closedPromise = readUntilClosed();

document.querySelector('button').addEventListener('click', async () => {
  // User clicked a button to close the serial port.
  keepReading = false;
  // Force reader.read() to resolve immediately and subsequently
  // call reader.releaseLock() in the loop example above.
  reader.cancel();
  await closedPromise;
});

変換ストリームを使用する場合、シリアルポートを閉じるのはより複雑です。前と同じように reader.cancel() を呼び出します。次に、writer.close()port.close() を呼び出します。これにより、変換ストリームを介してエラーが基盤となるシリアルポートに伝播されます。エラー伝播はすぐに行われないため、前に作成した readableStreamClosed プロミスと writableStreamClosed プロミスを使用して、port.readableport.writable がロック解除されたタイミングを検出する必要があります。reader をキャンセルするとストリームが中断されるため、結果として生じるエラーをキャッチして無視する必要があります。

// With transform streams.

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

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    reader.releaseLock();
    break;
  }
  // value is a string.
  console.log(value);
}

const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);

reader.cancel();
await readableStreamClosed.catch(() => { /* Ignore the error */ });

writer.close();
await writableStreamClosed;

await port.close();

接続と切断をリッスンする

シリアル ポートが USB デバイスによって提供されている場合、そのデバイスがシステムに接続されているか、接続されていない可能性があります。ウェブサイトでシリアルポートへのアクセスが許可されると、connect イベントと disconnect イベントを監視する必要があります。

navigator.serial.addEventListener("connect", (event) => {
  // TODO: Automatically open event.target or warn user a port is available.
});

navigator.serial.addEventListener("disconnect", (event) => {
  // TODO: Remove |event.target| from the UI.
  // If the serial port was opened, a stream error would be observed as well.
});

シグナルを処理する

シリアルポート接続を確立したら、デバイス検出とフロー制御のために、シリアルポートによって公開された信号を明示的にクエリして設定できます。これらのシグナルはブール値として定義されます。たとえば、Arduino などの一部のデバイスは、データ端末準備完了(DTR)信号を切り替えるとプログラミング モードになります。

出力シグナルの設定と入力シグナルの取得は、それぞれ port.setSignals()port.getSignals() を呼び出すことで行われます。以下の使用例をご覧ください。

// Turn off Serial Break signal.
await port.setSignals({ break: false });

// Turn on Data Terminal Ready (DTR) signal.
await port.setSignals({ dataTerminalReady: true });

// Turn off Request To Send (RTS) signal.
await port.setSignals({ requestToSend: false });
const signals = await port.getSignals();
console.log(`Clear To Send:       ${signals.clearToSend}`);
console.log(`Data Carrier Detect: ${signals.dataCarrierDetect}`);
console.log(`Data Set Ready:      ${signals.dataSetReady}`);
console.log(`Ring Indicator:      ${signals.ringIndicator}`);

ストリームの変換

シリアル デバイスからデータを受信する場合、必ずしもすべてのデータを一度に取得できるとは限りません。任意のチャンク化が可能です。詳細については、Streams API のコンセプトをご覧ください。

これを処理するには、TextDecoderStream などの組み込み変換ストリームを使用するか、受信ストリームを解析して解析されたデータを返す独自の変換ストリームを作成します。変換ストリームは、シリアル デバイスとストリームを消費する読み取りループの間に配置されます。データが使用される前に任意の変換を適用できます。組み立てラインのように考えてみましょう。ウィジェットがラインに沿って移動するにつれて、ラインの各ステップでウィジェットが変更され、最終的な宛先に到達するまでに完全に機能するウィジェットになります。

飛行機工場の画像
第二次世界大戦のキャッスル ブロムウィッチ飛行機工場

たとえば、ストリームを使用して行区切りに基づいてチャンク化する変換ストリーム クラスを作成する方法について考えてみましょう。その transform() メソッドは、ストリームが新しいデータを受信するたびに呼び出されます。データをキューに追加するか、後で使用するために保存します。flush() メソッドはストリームが閉じられたときに呼び出され、まだ処理されていないデータを処理します。

変換ストリーム クラスを使用するには、受信ストリームをパイプで転送する必要があります。シリアルポートから読み取るの 3 番目のコードサンプルでは、元の入力ストリームは TextDecoderStream のみをパイプ処理しているため、pipeThrough() を呼び出して新しい LineBreakTransformer でパイプ処理する必要があります。

class LineBreakTransformer {
  constructor() {
    // A container for holding stream data until a new line.
    this.chunks = "";
  }

  transform(chunk, controller) {
    // Append new chunks to existing chunks.
    this.chunks += chunk;
    // For each line breaks in chunks, send the parsed lines out.
    const lines = this.chunks.split("\r\n");
    this.chunks = lines.pop();
    lines.forEach((line) => controller.enqueue(line));
  }

  flush(controller) {
    // When the stream is closed, flush any remaining chunks out.
    controller.enqueue(this.chunks);
  }
}
const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable
  .pipeThrough(new TransformStream(new LineBreakTransformer()))
  .getReader();

シリアル デバイスの通信の問題をデバッグするには、port.readabletee() メソッドを使用して、シリアル デバイスとの間で送受信されるストリームを分割します。作成された 2 つのストリームは個別に使用でき、1 つをコンソールに出力して検査できます。

const [appReadable, devReadable] = port.readable.tee();

// You may want to update UI with incoming data from appReadable
// and log incoming data in JS console for inspection from devReadable.

シリアルポートへのアクセス権を取り消す

ウェブサイトは、SerialPort インスタンスで forget() を呼び出して、保持する必要がなくなったシリアルポートへのアクセス権をクリーンアップできます。たとえば、多くのデバイスで共有されるコンピュータで使用される教育用ウェブ アプリケーションの場合、ユーザー作成の権限が大量に蓄積されると、ユーザー エクスペリエンスが低下します。

// Voluntarily revoke access to this serial port.
await port.forget();

forget() は Chrome 103 以降で利用できる機能であるため、次の手順でこの機能がサポートされているかどうかを確認します。

if ("serial" in navigator && "forget" in SerialPort.prototype) {
  // forget() is supported.
}

開発のヒント

Chrome で Web Serial API をデバッグするのは簡単です。内部ページ about://device-log では、シリアル デバイス関連のすべてのイベントを 1 か所で確認できます。

Web Serial API のデバッグ用内部ページのスクリーンショット。
Web Serial API のデバッグ用 Chrome の内部ページ。

Codelab

Google Developer Codelab では、Web Serial API を使用して BBC micro:bit ボードを操作し、5x5 LED マトリックスに画像を表示します。

ブラウザ サポート

Web Serial API は、Chrome 89 のすべてのデスクトップ プラットフォーム(ChromeOS、Linux、macOS、Windows)で利用できます。

ポリフィル

Android では、WebUSB API と Serial API ポリフィルを使用することで、USB ベースのシリアルポートをサポートできます。このポリフィルは、WebUSB API 経由でデバイスにアクセスできるハードウェアとプラットフォームに限定されます。これは、組み込みデバイス ドライバによって要求されていないためです。

セキュリティとプライバシー

仕様作成者は、強力なウェブ プラットフォーム機能へのアクセスを制御するで定義されているコア プリンシプル(ユーザー制御、透明性、人間工学など)を使用して、Web Serial API を設計して実装しました。この API の使用は、一度に 1 つのシリアル デバイスへのアクセスのみを許可する権限モデルによって制限されています。ユーザーは、ユーザー プロンプトに応じて、特定のシリアル デバイスを選択するためにアクティブな手順を行う必要があります。

セキュリティのトレードオフについて詳しくは、Web Serial API の説明のセキュリティプライバシーのセクションをご覧ください。

フィードバック

Chrome チームは、Web Serial API に関するご意見やご感想をお待ちしております。

API 設計について

API が想定どおりに機能していない点はありますか?または、アイデアを実装するために必要なメソッドやプロパティが不足していますか?

Web Serial API GitHub リポジトリで仕様に関する問題を報告するか、既存の問題にコメントを追加してください。

実装に関する問題を報告する

Chrome の実装にバグは見つかりましたか?それとも実装が仕様と異なるのでしょうか?

https://new.crbug.com でバグを報告します。できるだけ詳細な情報を含め、バグの再現手順を簡単に説明してください。また、[コンポーネント] を Blink>Serial に設定します。Glitch は、簡単な再現手順をすばやく共有するのに適しています。

クリエイターを応援する

Web Serial API を使用する予定はありますか?皆様の公開サポートは、Chrome チームが機能に優先順位を付ける際や、他のブラウザ ベンダーにそれらの機能をサポートすることがいかに重要であるかを示すのに役立ちます。

ハッシュタグ #SerialAPI を使用して @ChromiumDev にツイートを送信し、どこでどのように使用しているかをお知らせください。

関連情報

デモ

謝辞

この記事のレビューを担当してくれた Reilly GrantJoe Medley に感謝いたします。 飛行機工場の写真は、Birmingham Museums Trust による Unsplash のものです。