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

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

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

Что такое API веб-последовательностей?

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

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

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

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

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

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

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

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

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

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

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

Обнаружение особенностей

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

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

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

Входные и выходные потоки в API Web Serial обрабатываются API потоков.

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

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

Если последовательное устройство возвращает текст, вы можете передать 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() . Обратите внимание, что API Web Serial поддерживает эту функцию в 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() вызывается при закрытии потока и обрабатывает все данные, которые ещё не были обработаны.

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

Советы разработчикам

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

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

Codelab

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

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

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

Полифилл

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

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

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

Чтобы понять компромиссы в области безопасности, ознакомьтесь с разделами, посвященными безопасности и конфиденциальности , в документе Web Serial API Explainer.

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

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

Расскажите нам о дизайне API

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

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

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

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

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

Показать поддержку

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

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

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

Демо-версии

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

Благодарим Рейли Гранта и Джо Медли за рецензии на эту статью. Фотография с завода по производству самолётов предоставлена Бирмингемским музейным фондом на Unsplash .