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

Web Serial API を使用すると、ウェブサイトとシリアル デバイス間の通信が可能になります。

François Beaufort
François Beaufort

Web Serial API とは

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

Web Serial API は、JavaScript を使用してシリアル デバイス間でデータの読み書きを行うための手段をウェブサイトに提供します。シリアル デバイスは、ユーザーのシステムのシリアルポート、またはシリアルポートをエミュレートするリムーバブル USB デバイスや Bluetooth デバイスを介して接続されます。

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

また、オペレーティング システムではアプリケーションが低レベルの USB API ではなく高レベルのシリアル API を使用して一部のシリアルポートと通信する必要があるため、この API は WebUSB の優れたコンパニオンとなります。

推奨されるユースケース

教育、愛好家、産業では、ユーザーは周辺機器をパソコンに接続します。これらのデバイスは多くの場合、カスタム ソフトウェアで使用されるシリアル接続を介してマイクロコントローラによって制御されます。これらのデバイスを制御するためのカスタム ソフトウェアには、ウェブ テクノロジーを使用して構築されているものもあります。

ウェブサイトは、ユーザーが手動でインストールしたエージェント アプリを介してデバイスと通信することがあります。また、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() を呼び出して単一のシリアルポートを選択するようにユーザーに促すか、ウェブサイトがアクセスを許可されているシリアルポートのリストを返す 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 製品識別子(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() は 2 つのプロパティ(valuedone ブール値)を非同期で返します。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.
  }
}

シリアル デバイスがテキストを返送する場合は、以下に示すように、TextDecoderStream を介して port.readable をパイプできます。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 Promise と writableStreamClosed Promise を使用して、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 Explainer のセキュリティプライバシーのセクションをご覧ください。

フィードバック

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

API の設計についてお聞かせください

API に想定外の動作はありますか?あるいは、アイデアを実装する必要があるメソッドやプロパティが不足しているのでしょうか。

Web Serial API GitHub リポジトリで仕様の問題を提出するか、既存の問題にご意見をお寄せください。

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

Chrome の実装にバグが見つかりましたか?それとも 実装が仕様と異なっているか?

https://new.crbug.com でバグを報告します。できる限り詳細を記載し、バグを再現するための簡単な手順を記載して、[Components] を Blink>Serial に設定します。Glitch は、すばやく簡単に再現する場合に適しています。

応援する

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

ハッシュタグ #SerialAPI を使用して @ChromiumDev 宛てにツイートを送信し、使用場所と使用方法をお知らせください。

関連情報

デモ

謝辞

この記事をレビューしてくれた Reilly GrantJoe Medley に感謝します。飛行機の工場の写真(撮影: Birmingham Discoverys TrustUnsplash