讀取及寫入序列埠

Web Serial API 可讓網站與序列裝置通訊。

François Beaufort
François Beaufort

什麼是 Web Serial API?

序列埠是雙向通訊介面,可逐位元組傳送及接收資料。

Web Serial API 可讓網站透過 JavaScript 讀取及寫入序列裝置。序列裝置可透過使用者系統上的序列埠連線,或是透過模擬序列埠的可移除 USB 和藍牙裝置連線。

換句話說,Web Serial API 可讓網站與序列裝置 (例如微控制器和 3D 印表機) 進行通訊,連結網路和實體世界。

這個 API 也是 WebUSB 的絕佳搭配工具,因為作業系統要求應用程式使用較高層級的序列 API 與某些序列埠通訊,而非低階 USB 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() 來回應使用者的手勢 (例如觸控或滑鼠點擊),提示使用者選取單一序列埠,或是從 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 和藍牙裝置,這個值可以安全地設為任何值,因為模擬程序會忽略這個值。

// 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 物件的 readablewritable 屬性會傳回 ReadableStreamWritableStream。這些會用於接收序列裝置的資料,並將資料傳送至序列裝置。兩者都使用 Uint8Array 例項進行資料傳輸。

當序列裝置傳送新資料時,port.readable.getReader().read() 會以非同步方式傳回兩個屬性: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 就會變成空值。

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();

透過 TextEncoderStream 管道傳送至 port.writable 的文字,如下所示。

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

關閉序列埠

如果 readablewritable 成員已解鎖port.close() 就會關閉序列埠,這表示已為其讀取器和寫入器分別呼叫 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()。這會透過轉換串流將錯誤傳播至基礎序列埠。由於錯誤傳播並不會立即發生,因此您必須使用先前建立的 readableStreamClosedwritableStreamClosed 承諾,偵測 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 裝置提供序列埠,則該裝置可能會連線或中斷連線。網站獲得存取序列埠的權限後,應監控 connectdisconnect 事件。

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.
});

處理信號

建立序列埠連線後,您可以明確查詢並設定序列埠公開的訊號,用於裝置偵測和流量控制。這些信號會定義為布林值。舉例來說,如果切換資料終端機就緒 (DTR) 信號,Arduino 等部分裝置就會進入程式設計模式。

您可以分別呼叫 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 等內建轉換串流,或是自行建立轉換串流,以便剖析傳入的串流並傳回剖析的資料。轉換串流位於序列裝置和讀取迴圈之間,而讀取迴圈會消耗串流。在使用資料前,可以套用任意轉換。您可以將其視為裝配線:當小工具沿著裝配線移動時,每個步驟都會修改小工具,因此當小工具到達最終目的地時,就會是完全運作的小工具。

飛機工廠的相片
第二次世界大戰期間的 Castle Bromwich 飛機工廠

舉例來說,請考慮如何建立轉換串流類別,以便取用串流並根據換行符號將其分割。每次資料流收到新資料時,就會呼叫其 transform() 方法。它可以將資料排入佇列,或儲存起來以供日後使用。串流關閉時會呼叫 flush() 方法,並處理尚未處理的任何資料。

如要使用轉換串流類別,您必須透過該類別管道傳送傳入的串流。在「從序列埠讀取」下方的第三個程式碼範例中,原始輸入串流只會透過 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() 方法,將傳入或傳出序列裝置的串流分割。建立的兩個串流可獨立使用,因此您可以將其中一個串流輸出至控制台以供檢查。

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.
}

開發人員提示

您可以使用內部頁面 about://device-log 輕鬆在 Chrome 中偵錯 Web Serial API,在該頁面中,您可以一次查看所有序列裝置相關事件。

用於偵錯 Web Serial API 的內部頁面螢幕截圖。
Chrome 中的內部頁面,用於偵錯 Web Serial API。

程式碼研究室

Google 開發人員程式碼研究室中,您將使用 Web Serial API 與 BBC micro:bit 板互動,在其 5x5 LED 矩陣上顯示圖片。

瀏覽器支援

在 Chrome 89 中,所有電腦平台 (ChromeOS、Linux、macOS 和 Windows) 皆可使用 Web Serial API。

聚酯纖維

在 Android 上,您可以使用 WebUSB API 和 Serial API polyfill 支援以 USB 為基礎的序列埠。這個 polyfill 僅適用於硬體和平台,因為這些裝置可透過 WebUSB API 存取,但未由內建裝置驅動程式宣告。

安全性和隱私權

規格作者根據「控管強大網路平台功能的存取權」一文中定義的核心原則,設計並實作 Web Serial API,包括使用者控制、資訊公開和人體工學。使用這個 API 的能力主要受到權限模型的限制,該模型會一次只授予單一序列裝置的存取權。使用者必須採取主動步驟,才能選取特定序列裝置,回應使用者提示。

如要瞭解安全性取捨,請參閱 Web Serial API 說明的「安全性」和「隱私權」部分。

意見回饋

Chrome 團隊很樂意聽取你對 Web Serial API 的想法和使用體驗。

請告訴我們 API 設計

API 是否有任何功能無法正常運作?或者,您是否缺少實作想法所需的方法或屬性?

請在 Web Serial API GitHub 存放區中提交規格問題,或在現有問題中加入您的想法。

回報實作問題

你是否發現 Chrome 實作項目有錯誤?還是實作方式與規格不同?

請前往 https://new.crbug.com 提交錯誤。請務必盡可能提供詳細資訊,提供重現錯誤的簡單操作說明,並將「元件」設為 Blink>SerialGlitch 非常適合用來快速分享重現問題的做法。

顯示支援

您是否打算使用 Web Serial API?您的公開支援有助於 Chrome 團隊將功能排定優先順序,並向其他瀏覽器供應商顯示支援這些功能的重要性。

使用主題標記 #SerialAPI 發送推文給 @ChromiumDev,告訴我們你在何處使用這項功能,以及使用方式。

實用連結

示範

特別銘謝

感謝 Reilly GrantJoe Medley 審查本文。飛機工廠相片,由 Birmingham Museums Trust 提供,並發布於 Unsplash