讀取及寫入序列埠

網站可透過 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() 函式會採用選用的物件常值,用於定義篩選器。這些 ID 用於將透過 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);
}

使用「自備緩衝區」讀取器從串流讀取資料時,您可以控管記憶體分配方式。呼叫 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 成員未鎖定,也就是已為各自的讀取器和寫入器呼叫 releaseLock(),則 port.close() 會關閉序列埠。

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 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 裝置提供,該裝置可能會連線或中斷與系統的連線。網站獲得序列埠存取權後,應監控 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 等內建轉換串流,也可以建立自己的轉換串流,以便剖析傳入的串流並傳回剖析的資料。轉換串流位於序列裝置和耗用串流的讀取迴圈之間。並在資料使用前套用任意轉換。這就像組裝線:當小工具沿著組裝線移動時,組裝線上的每個步驟都會修改小工具,因此小工具抵達最終目的地時,就是一個功能齊全的小工具。

飛機工廠的相片
第二次世界大戰時期的卡斯爾布羅姆威治飛機工廠

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

如要使用轉換串流類別,您需要透過管道傳送傳入的串流。在「Read from a serial port」(從序列埠讀取) 下方的第三個程式碼範例中,原始輸入串流只透過 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 的 Web Serial API 適用於所有電腦平台 (ChromeOS、Linux、macOS 和 Windows)。

Polyfill

在 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 回報錯誤。請務必盡可能提供詳細資料,並提供重現錯誤的簡單操作說明,且將「Components」設為 Blink>Serial

顯示支援

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

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

實用連結

示範

特別銘謝

感謝 Reilly GrantJoe Medley 審查本文。飛機工廠相片由 伯明罕博物館信託基金Unsplash 上提供。