讀取及寫入序列埠

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

使用「自備緩衝區」讀取器讀取串流時,你可以控制記憶體的分配方式。呼叫 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.writableTextEncoderStream 將文字傳送到裝置,如下所示。

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

處理信號

建立序列埠連線後,您可以明確查詢及設定由序列埠公開的信號,用於偵測裝置及控制流量。這些信號被定義為布林值。舉例來說,如果切換 Data Terminal Ready (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();

由於 Chrome 103 以上版本支援 forget(),請檢查下列項目是否支援這項功能:

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

開發人員提示

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

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

程式碼研究室

Google Developer 程式碼研究室中,您將使用 Web Serial API 與 BBC micro:bit 白板互動,藉此顯示 5x5 LED 矩陣上的圖片。

瀏覽器支援

Web Serial API 適用於所有執行 Chrome 89 的桌面平台 (ChromeOS、Linux、macOS 和 Windows)。

聚酯纖維

在 Android 上,您可以使用 WebUSB API 和 Serial API polyfill 支援 USB 型序列埠。這個 Polyfill 只適用於可透過 WebUSB API 存取裝置的硬體和平台,因為裝置尚未由內建的裝置驅動程式聲明擁有權。

安全性和隱私權

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

如要瞭解安全性的取捨,請參閱 Web Serial API Explainer 的安全性隱私權章節。

意見回饋:

Chrome 團隊想瞭解您對 Web Serial API 的想法和使用體驗。

告訴我們 API 設計

API 有沒有正常運作的問題嗎?或者您需要某些方法或屬性來實作構想嗎?

Web Serial API GitHub 存放區提交規格問題,或將您的想法新增至現有問題。

回報導入問題

您在執行 Chrome 時發現錯誤了嗎?還是實作與規格不同?

前往 https://new.crbug.com 回報錯誤。請務必盡可能附上詳細資料,提供重現錯誤的簡易操作說明,並將「元件」設為 Blink>SerialGlitch 適合用來分享快速簡易的提案。

展現支持

你打算使用 Web Serial API 嗎?您的公開支援可協助 Chrome 團隊決定功能的優先順序,讓其他瀏覽器廠商瞭解這些功能有多重要。

請使用主題標記 #SerialAPI 將 Tweet 訊息傳送至 @ChromiumDev,並告訴我們您的使用地點和方式。

實用連結

試聽帶

特別銘謝

感謝 Reilly GrantJoe Medley 對這篇文章的評論。 Birmingham Museums TrustUnsplash 網站上提供的飛機工廠相片。