Đọc từ và ghi vào cổng nối tiếp

Web Serial API cho phép các trang web giao tiếp với thiết bị nối tiếp.

François Beaufort
François Beaufort

Web Serial API là gì?

Cổng nối tiếp là một giao diện giao tiếp hai chiều cho phép gửi và nhận dữ liệu theo từng byte.

Web Serial API giúp các trang web đọc và ghi vào thiết bị nối tiếp bằng JavaScript. Các thiết bị nối tiếp được kết nối thông qua một cổng nối tiếp trên hệ thống của người dùng hoặc thông qua thiết bị Bluetooth và USB có thể tháo rời mô phỏng một cổng nối tiếp.

Nói cách khác, Web Serial API là cầu nối giữa web và thế giới thực bằng cách cho phép các trang web giao tiếp với thiết bị nối tiếp, chẳng hạn như bộ vi điều khiển và máy in 3D.

API này cũng là một lựa chọn đồng hành tuyệt vời cho WebUSB khi mà các hệ điều hành đòi hỏi để giao tiếp với một số cổng nối tiếp bằng các cổng cấp cao hơn nối tiếp thay vì API USB cấp thấp.

Các trường hợp sử dụng được đề xuất

Trong lĩnh vực giáo dục, sản xuất theo sở thích và công nghiệp, người dùng kết nối thiết bị ngoại vi thiết bị sang máy tính của họ. Các thiết bị này thường do vi điều khiển qua kết nối nối tiếp do phần mềm tuỳ chỉnh sử dụng. Một số thành phần tuỳ chỉnh phần mềm điều khiển các thiết bị này được xây dựng bằng công nghệ web:

Trong một số trường hợp, các trang web giao tiếp với thiết bị thông qua một tác nhân mà người dùng đã cài đặt theo cách thủ công. Còn ở các quốc gia khác, ứng dụng lại được phân phối trong ứng dụng đóng gói thông qua một khung, chẳng hạn như Electron. Và nói cách khác, người dùng được yêu cầu thực hiện thêm một bước như sao chép ứng dụng đã biên dịch sang thiết bị qua ổ USB flash.

Trong tất cả các trường hợp này, trải nghiệm người dùng sẽ được cải thiện bằng cách cung cấp hoạt động giao tiếp giữa trang web và thiết bị mà trang web đang kiểm soát.

Trạng thái hiện tại

Bước Trạng thái
1. Tạo thông báo giải thích Hoàn tất
2. Tạo bản nháp ban đầu của thông số kỹ thuật Hoàn tất
3. Thu thập ý kiến phản hồi và lặp lại thiết kế Hoàn tất
4. Bản dùng thử theo nguyên gốc Hoàn tất
5. Ra mắt Hoàn tất

Sử dụng Web Serial API

Phát hiện tính năng

Để kiểm tra xem Web Serial API có được hỗ trợ hay không, hãy dùng:

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

Mở cổng nối tiếp

Web Serial API không đồng bộ theo thiết kế. Thao tác này ngăn giao diện người dùng của trang web chặn khi đang chờ đầu vào. Điều này rất quan trọng vì dữ liệu nối tiếp có thể nhận được bất cứ lúc nào, đòi hỏi phải có cách nghe mới.

Để mở cổng nối tiếp, trước tiên hãy truy cập vào đối tượng SerialPort. Để làm được điều này, bạn có thể nhắc người dùng chọn một cổng nối tiếp bằng cách gọi navigator.serial.requestPort() để phản hồi một cử chỉ của người dùng, chẳng hạn như chạm hoặc nhấp chuột hoặc chọn một từ navigator.serial.getPorts() để trả về danh sách cổng nối tiếp mà trang web đã được cấp quyền truy cập.

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

Hàm navigator.serial.requestPort() nhận một giá trị cố định đối tượng không bắt buộc xác định bộ lọc. Các thẻ đó dùng để khớp với bất kỳ thiết bị nối tiếp nào được kết nối qua USB với nhà cung cấp USB bắt buộc (usbVendorId) và sản phẩm USB tùy chọn (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();
Ảnh chụp màn hình lời nhắc cổng nối tiếp trên một trang web
Lời nhắc người dùng chọn BBC micro:bit

Khi gọi requestPort(), người dùng sẽ được nhắc chọn một thiết bị và trả về một Đối tượng SerialPort. Sau khi bạn có đối tượng SerialPort, hãy gọi port.open() có tốc độ baud mong muốn sẽ mở cổng nối tiếp. Từ điển baudRate thành viên chỉ định tốc độ gửi dữ liệu qua đường nối tiếp. Ngôn ngữ này được thể hiện bằng đơn vị bit/giây (bps). Hãy xem tài liệu của thiết bị để biết giá trị chính xác do tất cả dữ liệu bạn gửi và nhận sẽ bị vô nghĩa nếu đây là chỉ định không chính xác. Đối với một số thiết bị USB và Bluetooth mô phỏng một nối tiếp cổng này có thể được đặt an toàn thành bất kỳ giá trị nào vì nó bị bỏ qua mô phỏng.

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

Bạn cũng có thể chỉ định bất kỳ tuỳ chọn nào dưới đây khi mở cổng nối tiếp. Các là không bắt buộc và có giá trị mặc định thuận tiện.

  • dataBits: Số bit dữ liệu trên mỗi khung (7 hoặc 8).
  • stopBits: Số bit dừng ở cuối một khung (1 hoặc 2).
  • parity: Chế độ đồng nhất ("none", "even" hoặc "odd").
  • bufferSize: Kích thước của vùng đệm đọc và ghi cần tạo (phải nhỏ hơn 16 MB).
  • flowControl: Chế độ kiểm soát luồng ("none" hoặc "hardware").

Đọc qua cổng nối tiếp

Các luồng đầu vào và đầu ra trong Web Serial API do API Luồng xử lý.

Sau khi thiết lập kết nối cổng nối tiếp, readablewritable các thuộc tính từ đối tượng SerialPort sẽ trả về ReadableStreamWritableStream. Các cookie đó sẽ được dùng để nhận dữ liệu và gửi dữ liệu đến thiết bị nối tiếp. Cả hai đều sử dụng phiên bản Uint8Array để chuyển dữ liệu.

Khi thiết bị nối tiếp nhận được dữ liệu mới, port.readable.getReader().read() trả về hai thuộc tính không đồng bộ: value và boolean done. Nếu done là true, cổng nối tiếp đã đóng hoặc không còn dữ liệu nào trong năm Việc gọi port.readable.getReader() sẽ tạo một trình đọc và khoá readable để nó. Mặc dù readable đang khoá nhưng bạn không thể đóng cổng nối tiếp.

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

Một số lỗi đọc cổng nối tiếp không nghiêm trọng có thể xảy ra trong một số điều kiện như tràn vùng đệm, lỗi đóng khung hoặc lỗi tương đương. Những quảng cáo đó được gửi dưới dạng ngoại lệ và có thể phát hiện được bằng cách thêm một vòng lặp khác vào vòng lặp trước kiểm tra port.readable. Phương pháp này hiệu quả vì miễn là lỗi không nghiêm trọng, một ReadableStream mới sẽ được tạo tự động. Nếu xảy ra lỗi nghiêm trọng xảy ra, chẳng hạn như thiết bị nối tiếp bị xoá, thì port.readable trở thành rỗng.

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

Nếu thiết bị nối tiếp gửi văn bản trở lại, bạn có thể chuyển port.readable qua một TextDecoderStream như được hiển thị dưới đây. TextDecoderStream là một luồng biến đổi để lấy tất cả Uint8Array phần và chuyển đổi chúng thành chuỗi.

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

Bạn có thể kiểm soát cách bộ nhớ được phân bổ khi đọc từ luồng bằng cách sử dụng tính năng "Lấy bộ đệm riêng" người đọc. Gọi port.readable.getReader({ mode: "byob" }) để nhận giao diện ReadableStreamBYOBReader và cung cấp ArrayBuffer của riêng bạn khi gọi read(). Lưu ý rằng API Web Serial hỗ trợ tính năng này trong Chrome 106 trở lên.

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

Dưới đây là ví dụ về cách sử dụng lại vùng đệm ngoài 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`.
}

Dưới đây là một ví dụ khác về cách đọc một lượng dữ liệu cụ thể qua cổng nối tiếp:

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

Ghi vào một cổng nối tiếp

Để gửi dữ liệu đến một thiết bị nối tiếp, hãy truyền dữ liệu đến port.writable.getWriter().write(). Đang gọi cho releaseLock() Bạn phải có port.writable.getWriter() để đóng cổng nối tiếp sau này.

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

Gửi tin nhắn văn bản đến thiết bị thông qua một TextEncoderStream được nối tới port.writable như minh hoạ dưới đây.

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

Đóng cổng nối tiếp

port.close() đóng cổng nối tiếp nếu các thành phần readablewritable của nó được mở khoá, nghĩa là releaseLock() đã được gọi cho độc giả và tác giả.

await port.close();

Tuy nhiên, khi liên tục đọc dữ liệu từ một thiết bị nối tiếp bằng vòng lặp, port.readable sẽ luôn bị khoá cho đến khi gặp lỗi. Trong phần này trong trường hợp khác, việc gọi reader.cancel() sẽ buộc reader.read() phải phân giải ngay lập tức với { value: undefined, done: true }, nhờ đó cho phép để gọi 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;
});

Việc đóng cổng nối tiếp sẽ phức tạp hơn khi sử dụng luồng biến đổi. Gọi reader.cancel() như trước đây. Sau đó, hãy gọi writer.close()port.close(). Thao tác này sẽ truyền lỗi thông qua chuyển đổi luồng sang cổng nối tiếp cơ bản. Do sự lan truyền lỗi không xảy ra ngay lập tức, bạn cần phải sử dụng readableStreamClosedwritableStreamClosed lời hứa được tạo trước đó để phát hiện khi port.readableport.writable đã được mở khoá. Việc loại bỏ reader sẽ khiến phát trực tuyến bị huỷ bỏ; đây là lý do bạn phải phát hiện và bỏ qua lỗi phát sinh.

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

Nghe thông báo kết nối và ngắt kết nối

Nếu thiết bị USB cung cấp cổng nối tiếp thì thiết bị đó có thể đã được kết nối hoặc bị ngắt kết nối khỏi hệ thống. Khi trang web đã được cấp quyền để một cổng nối tiếp, cổng này sẽ giám sát các sự kiện 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.
});

Xử lý tín hiệu

Sau khi thiết lập kết nối cổng nối tiếp, bạn có thể truy vấn và thiết lập tín hiệu do cổng nối tiếp hiển thị để phát hiện thiết bị và điều khiển luồng. Các tín hiệu được xác định là giá trị boolean. Ví dụ: một số thiết bị như Arduino sẽ chuyển sang chế độ lập trình nếu tín hiệu Data Terminal Sẵn sàng (DTR) là bật/tắt.

Việc đặt tín hiệu đầu ra và nhận tín hiệu đầu vào tương ứng được thực hiện bằng cách đang gọi port.setSignals()port.getSignals(). Hãy xem các ví dụ về cách sử dụng ở bên dưới.

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

Biến đổi luồng

Khi bạn nhận được dữ liệu từ thiết bị nối tiếp, không phải lúc nào bạn cũng nhận được tất cả dữ liệu cùng một lúc. Khối này có thể được phân đoạn tuỳ ý. Để biết thêm thông tin, hãy xem Khái niệm về Streams API.

Để giải quyết vấn đề này, bạn có thể sử dụng một số luồng chuyển đổi tích hợp sẵn như TextDecoderStream hoặc tạo luồng biến đổi của riêng bạn để có thể phân tích cú pháp luồng đến và trả về dữ liệu đã phân tích cú pháp. Luồng biến đổi ở lại giữa thiết bị nối tiếp và vòng lặp đọc đang sử dụng luồng. Chiến dịch này có thể áp dụng phép biến đổi tuỳ ý trước khi sử dụng dữ liệu. Hãy nghĩ về điều này như một dây chuyền lắp ráp: khi một tiện ích xuất hiện trong dòng, mỗi bước trong dòng sẽ sửa đổi để tiện ích con đó đi tới đích cuối cùng, nó tiện ích hoạt động.

Ảnh nhà máy máy bay
Nhà máy sản xuất máy bay trong Lâu đài Bromwich trong Thế chiến II

Ví dụ: hãy xem xét cách tạo một lớp luồng chuyển đổi sử dụng và phân đoạn dữ liệu đó dựa trên dấu ngắt dòng. Phương thức transform() của lớp này được gọi mỗi khi luồng nhận được dữ liệu mới. Phương thức này có thể thêm dữ liệu vào hàng đợi hoặc lưu lại để dùng sau. Phương thức flush() được gọi khi luồng bị đóng và nó xử lý mọi dữ liệu chưa được xử lý.

Để sử dụng lớp chuyển đổi luồng, bạn cần chuyển một luồng đến qua nó. Trong ví dụ về mã thứ ba trong mục Đọc từ cổng nối tiếp, luồng đầu vào ban đầu chỉ được dẫn qua TextDecoderStream, vì vậy chúng tôi cần gọi pipeThrough() để chuyển nó qua LineBreakTransformer mới của chúng tôi.

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

Để gỡ lỗi các sự cố giao tiếp thiết bị nối tiếp, hãy sử dụng phương thức tee() của port.readable để phân tách các luồng đến hoặc từ thiết bị nối tiếp. Hai luồng được tạo có thể được dùng một cách độc lập và điều này cho phép bạn in một luồng vào bảng điều khiển để kiểm tra.

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.

Thu hồi quyền truy cập vào cổng nối tiếp

Trang web có thể dọn dẹp quyền truy cập vào một cổng nối tiếp mà trang web này không còn hoạt động nữa muốn giữ lại bằng cách gọi forget() trên thực thể SerialPort. Cho ví dụ: đối với ứng dụng web giáo dục dùng trên máy tính dùng chung với trên thiết bị, một số lượng lớn các quyền do người dùng tạo tích luỹ sẽ tạo ra trải nghiệm người dùng.

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

forget() có trong Chrome 103 trở lên, hãy kiểm tra xem tính năng này có được hỗ trợ bằng các tính năng sau:

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

Mẹo dành cho nhà phát triển

Bạn có thể dễ dàng gỡ lỗi Web Serial API trong Chrome thông qua trang nội bộ, about://device-log nơi bạn có thể xem tất cả sự kiện liên quan đến thiết bị nối tiếp trong một vị trí duy nhất.

Ảnh chụp màn hình trang nội bộ để gỡ lỗi Web Serial API.
Trang nội bộ trong Chrome để gỡ lỗi Web Serial API.

Lớp học lập trình

Trong Lớp học lập trình dành cho nhà phát triển của Google, bạn sẽ sử dụng Web Serial API để tương tác bằng bảng BBC micro:bit để hiển thị hình ảnh trên ma trận LED 5x5.

Hỗ trợ trình duyệt

Web Serial API có trên tất cả các nền tảng dành cho máy tính (ChromeOS, Linux, macOS, và Windows) trong Chrome 89.

Ống polyfill

Trên Android, có thể hỗ trợ cổng nối tiếp dựa trên USB bằng cách sử dụng WebUSB API và polyfill API nối tiếp. Đoạn polyfill này chỉ dùng được cho phần cứng và nền tảng nơi thiết bị có thể truy cập được qua WebUSB API vì ứng dụng này chưa do một trình điều khiển thiết bị tích hợp xác nhận quyền sở hữu.

Bảo mật và quyền riêng tư

Các tác giả của thông số kỹ thuật này đã thiết kế và triển khai Web Serial API bằng cách sử dụng nguyên tắc xác định trong Kiểm soát quyền truy cập vào các tính năng nền tảng web mạnh mẽ, bao gồm cả quyền kiểm soát của người dùng, tính minh bạch và công thái học. Khả năng sử dụng tính năng này API chủ yếu chịu sự kiểm soát của một mô hình quản lý quyền chỉ cấp quyền truy cập vào một tài khoản duy nhất thiết bị nối tiếp cùng một lúc. Để phản hồi lời nhắc của người dùng, người dùng phải kích hoạt để chọn một thiết bị nối tiếp cụ thể.

Để hiểu rõ các yếu tố đánh đổi về bảo mật, hãy xem phần bảo mậtquyền riêng tư trong Công cụ giải thích Web Serial API.

Phản hồi

Nhóm Chrome rất muốn biết suy nghĩ và trải nghiệm của bạn với Web Serial API.

Cho chúng tôi biết về thiết kế API

Có điều gì về API không hoạt động như mong đợi không? Hoặc có còn thiếu phương thức hoặc thuộc tính nào mà bạn cần để triển khai ý tưởng của mình?

Gửi vấn đề về thông số kỹ thuật trên kho lưu trữ GitHub về Web Serial API hoặc thêm về một vấn đề hiện tại.

Báo cáo sự cố về triển khai

Bạn có phát hiện lỗi trong quá trình triển khai Chrome không? Hoặc là triển khai khác với thông số kỹ thuật không?

Báo cáo lỗi tại https://new.crbug.com. Hãy nhớ bao gồm nhiều chi tiết nhất có thể, cung cấp hướng dẫn đơn giản để tái tạo lỗi và Đã đặt Thành phần thành Blink>Serial. Lỗi trục phù hợp với chia sẻ các bản ghi lại nhanh chóng và dễ dàng.

Thể hiện sự ủng hộ

Bạn có định sử dụng Web Serial API không? Sự hỗ trợ công khai của bạn sẽ giúp Chrome nhóm ưu tiên các tính năng và cho các nhà cung cấp trình duyệt khác biết tầm quan trọng của việc này hỗ trợ họ.

Gửi một bài đăng đến @ChromiumDev kèm theo hashtag #SerialAPI đồng thời cho chúng tôi biết bạn đang sử dụng ở đâu và như thế nào.

Các đường liên kết hữu ích

Bản thu thử

Xác nhận

Cảm ơn Reilly GrantJoe Medley đã đánh giá bài viết này. Hình ảnh nhà máy máy bay của Birmingham Museums Trust trên Unsplash.