Чтение и запись в последовательный порт

API Web Serial позволяет веб-сайтам взаимодействовать с последовательными устройствами.

Франсуа Бофор
François Beaufort

Что такое Web Serial API?

Последовательный порт — это двунаправленный интерфейс связи, позволяющий отправлять и получать данные побайтно.

API Web Serial предоставляет веб-сайтам возможность читать и записывать данные на последовательное устройство с помощью JavaScript. Последовательные устройства подключаются либо через последовательный порт в системе пользователя, либо через съемные USB- и Bluetooth-устройства, эмулирующие последовательный порт.

Иными словами, Web Serial API соединяет интернет и физический мир, позволяя веб-сайтам взаимодействовать с последовательными устройствами, такими как микроконтроллеры и 3D-принтеры.

Этот API также отлично дополняет WebUSB , поскольку операционные системы требуют от приложений взаимодействия с некоторыми последовательными портами с использованием высокоуровневого API для работы с последовательными портами, а не низкоуровневого API USB.

Предлагаемые варианты использования

В образовательном, любительском и промышленном секторах пользователи подключают периферийные устройства к своим компьютерам. Эти устройства часто управляются микроконтроллерами через последовательное соединение, используемое специализированным программным обеспечением. Некоторое специализированное программное обеспечение для управления этими устройствами создается с использованием веб-технологий:

В некоторых случаях веб-сайты взаимодействуют с устройством через агентское приложение, которое пользователи устанавливают вручную. В других случаях приложение поставляется в упакованном виде через фреймворк, такой как Electron. А в третьих случаях пользователю необходимо выполнить дополнительный шаг, например, скопировать скомпилированное приложение на устройство с помощью USB-накопителя.

Во всех этих случаях пользовательский опыт будет улучшен за счет обеспечения прямой связи между веб-сайтом и управляемым им устройством.

Текущий статус

Шаг Статус
1. Создайте пояснительное видео. Полный
2. Создайте первоначальный черновик спецификации. Полный
3. Сбор отзывов и итеративная доработка дизайна. Полный
4. Испытание происхождения Полный
5. Запуск Полный

Использование API веб-последовательного порта

Обнаружение признаков

Чтобы проверить, поддерживается ли Web Serial API, используйте:

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

Откройте последовательный порт

API Web Serial по своей конструкции является асинхронным. Это предотвращает блокировку пользовательского интерфейса веб-сайта в ожидании ввода, что важно, поскольку данные, передаваемые по последовательному порту, могут быть получены в любое время, и для их отслеживания необходим соответствующий механизм.

Чтобы открыть последовательный порт, сначала получите доступ к объекту 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 определяет скорость передачи данных по последовательному порту. Она выражается в битах в секунду (бит/с). Проверьте документацию вашего устройства, чтобы узнать правильное значение, так как все отправляемые и получаемые данные будут бессмысленными, если оно указано неправильно. Для некоторых 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 МБ).
  • flowControl : Режим управления потоком (либо "none" , либо "hardware" ).

Чтение с последовательного порта

Обработка входных и выходных потоков в Web Serial API осуществляется с помощью Streams API.

После установления соединения через последовательный порт свойства readable и writable объекта SerialPort возвращают объекты ReadableStream и WritableStream . Они будут использоваться для приема и отправки данных на последовательное устройство. Оба объекта используют экземпляры Uint8Array для передачи данных.

Когда с последовательного устройства поступают новые данные, port.readable.getReader().read() асинхронно возвращает два свойства: value и логическое значение done . Если done равно true, последовательный порт закрыт или данные больше не поступают. Вызов port.readable.getReader() создает объект Reader и блокирует 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.
  }
}

Если последовательное устройство отправляет текст в ответ, вы можете передать port.readable через TextDecoderStream , как показано ниже. 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 , и укажите свой собственный ArrayBuffer при вызове read() . Обратите внимание, что 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() . Для последующего закрытия последовательного порта необходимо вызвать releaseLock() для port.writable.getWriter() .

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

Закрыть последовательный порт

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 и writableStreamClosed для определения момента разблокировки port.readable и port.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}`);

Преобразование потоков

При получении данных с последовательного устройства вы не обязательно получите все данные сразу. Они могут быть разбиты на произвольные фрагменты. Для получения дополнительной информации см. раздел «Концепции API потоков» .

Для решения этой проблемы можно использовать встроенные потоки преобразования, такие как TextDecoderStream , или создать собственный поток преобразования, который позволит вам анализировать входящий поток и возвращать проанализированные данные. Поток преобразования находится между последовательным устройством и циклом чтения, который обрабатывает поток. Он может применять произвольное преобразование до того, как данные будут обработаны. Представьте это как конвейер: по мере продвижения изделия по линии каждый шаг изменяет его, так что к моменту достижения конечного пункта назначения это уже полностью функционирующее изделие.

Фотография авиационного завода
Авиационный завод в Касл-Бромвиче во время Второй мировой войны

Например, рассмотрим, как создать класс потока преобразования, который обрабатывает поток и разбивает его на фрагменты в зависимости от переносов строк. Его метод transform() вызывается каждый раз, когда поток получает новые данные. Он может либо добавить данные в очередь, либо сохранить их для последующего использования. Метод flush() вызывается при закрытии потока и обрабатывает любые данные, которые еще не были обработаны.

Для использования класса transform stream необходимо передать входящий поток через него. В третьем примере кода в разделе «Чтение из последовательного порта » исходный входной поток был передан только через 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();

Для отладки проблем связи с последовательным устройством используйте метод tee() класса port.readable , чтобы разделить потоки данных, идущие к последовательному устройству или от него. Два созданных потока можно обрабатывать независимо, что позволяет вывести один из них на консоль для анализа.

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.

Отозвать доступ к последовательному порту

Веб-сайт может очистить разрешения на доступ к последовательному порту, которые ему больше не нужны, вызвав метод forget() для экземпляра SerialPort . Например, для образовательного веб-приложения, используемого на компьютере, которым пользуются многие устройства, большое количество накопленных разрешений, созданных пользователями, приводит к ухудшению пользовательского опыта.

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

Поскольку forget() доступна в Chrome версии 103 и более поздних, проверьте, поддерживается ли она следующим образом:

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

Советы для разработчиков

Отладка Web Serial API в Chrome проста благодаря внутренней странице about://device-log , где вы можете увидеть все события, связанные с последовательными устройствами, в одном месте.

Скриншот внутренней страницы для отладки Web Serial API.
Внутренняя страница в Chrome для отладки Web Serial API.

Кодлаб

В рамках практического занятия для разработчиков Google вы будете использовать Web Serial API для взаимодействия с платой BBC micro:bit и отображения изображений на её светодиодной матрице 5x5.

Поддержка браузеров

API Web Serial доступен на всех настольных платформах (ChromeOS, Linux, macOS и Windows) в Chrome 89.

Полиэфирный наполнитель

На Android поддержка последовательных портов на основе USB возможна с использованием API WebUSB и полифила Serial API . Этот полифил ограничен оборудованием и платформами, где устройство доступно через API WebUSB, поскольку он не был заявлен встроенным драйвером устройства.

Безопасность и конфиденциальность

Авторы спецификации разработали и реализовали Web Serial API, используя основные принципы, изложенные в документе «Контроль доступа к мощным функциям веб-платформы» , включая пользовательский контроль, прозрачность и эргономику. Возможность использования этого API в основном ограничена моделью разрешений, которая предоставляет доступ только к одному последовательному устройству одновременно. В ответ на запрос пользователя, он должен предпринять активные действия для выбора конкретного последовательного устройства.

Чтобы понять компромиссы в вопросах безопасности, ознакомьтесь с разделами «Безопасность» и «Конфиденциальность» в пояснении к Web Serial API.

Обратная связь

Команда Chrome будет рада узнать ваше мнение и опыт использования Web Serial API.

Расскажите о проектировании API.

Есть ли какие-то особенности API, которые работают не так, как ожидалось? Или отсутствуют методы или свойства, необходимые для реализации вашей идеи?

Создайте заявку на изменение спецификации в репозитории Web Serial API на GitHub или поделитесь своими мыслями в уже существующей заявке.

Сообщить о проблеме с реализацией

Вы обнаружили ошибку в реализации Chrome? Или реализация отличается от спецификации?

Сообщите об ошибке на сайте https://new.crbug.com . Обязательно укажите как можно больше подробностей, предоставьте простые инструкции по воспроизведению ошибки и убедитесь, что для компонентов установлено значение Blink>Serial .

Проявите поддержку

Планируете ли вы использовать Web Serial API? Ваша публичная поддержка помогает команде Chrome расставлять приоритеты в разработке новых функций и показывает другим производителям браузеров, насколько важно их поддерживать.

Отправьте твит @ChromiumDev , используя хэштег #SerialAPI , и расскажите, где и как вы его используете.

Полезные ссылки

Демонстрации

Благодарности

Благодарим Рейли Гранта и Джо Медли за рецензирование этого документа.