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

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

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

Что такое API веб-сериалов?

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

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

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

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

Рекомендуемые варианты использования

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

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

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

Текущее состояние

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

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

Обнаружение функций

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

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

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

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

Чтобы открыть последовательный порт, сначала обратитесь к объекту 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 обрабатываются API Streams.

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

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

Если последовательное устройство отправляет текст обратно, вы можете передать 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);
}

Вы можете контролировать распределение памяти при чтении из потока, используя программу чтения «Принесите свой собственный буфер». Вызовите 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}`);

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

Когда вы получаете данные от последовательного устройства, вы не обязательно получите все данные сразу. Он может быть произвольно разбит на части. Дополнительные сведения см. в разделе Концепции Streams API .

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

Фото авиазавода
Авиационный завод в замке Бромвич времен Второй мировой войны

Например, рассмотрим, как создать класс потока преобразования, который принимает поток и разбивает его на фрагменты на основе разрывов строк. Его метод transform() вызывается каждый раз, когда поток получает новые данные. Он может либо поставить данные в очередь, либо сохранить их на будущее. Методlush 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.
}

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

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

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

Кодлаб

В лаборатории кода Google Developer вы будете использовать Web Serial API для взаимодействия с платой 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 в первую очередь ограничивается моделью разрешений, которая предоставляет доступ только к одному последовательному устройству одновременно. В ответ на запрос пользователя пользователь должен предпринять активные действия для выбора конкретного последовательного устройства.

Чтобы понять компромиссы в области безопасности, ознакомьтесь с разделами безопасности и конфиденциальности в объяснении веб-последовательного API.

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

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

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

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

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

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

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

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

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

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

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

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

Демо

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

Спасибо Рейли Гранту и Джо Медли за рецензии на эту статью. Фотография завода по производству самолетов, сделанная Birmingham Museums Trust на Unsplash .