Back

Интересные возможности Web Serial API

Интересные возможности Web Serial API

Web Serial API позволяет веб-странице открывать двунаправленный байтовый поток к физическому устройству, работающему по последовательному интерфейсу, — USB-to-serial адаптеру, отладочной плате микроконтроллера, 3D-принтеру или сопряжённому устройству Bluetooth Classic, — без необходимости устанавливать нативное приложение или расширение браузера. API входит в одно семейство с браузерными интерфейсами доступа к оборудованию: MediaDevices (камеры и микрофоны), WebUSB (прямой доступ к USB-интерфейсам) и WebHID (геймпады и клавиатуры): страница запрашивает доступ, браузер показывает диалог разрешений, и пользователь выбирает устройство.

Прежде чем приступать к реализации проекта выходного дня, стоит разобраться с поддержкой API. Согласно таблице совместимости браузеров MDN, Web Serial поддерживается на десктопе в Chrome 89+, Edge 89+, Opera 75+ и Firefox 151+; Safari не объявлял о поддержке. API требует безопасного контекста (HTTPS или localhost) и пользовательского жеста для вызова requestPort() — запросить порт при загрузке страницы не получится. В этой статье рассматриваются шесть конкретных категорий проектов, которые можно построить на основе этого API: для каждого приведён специфичный для Web Serial код, описание аппаратного сопряжения и главная ошибка, с которой сталкиваются в первую очередь.

Ключевые выводы

  • Web Serial поддерживается на десктопе в Chrome 89+, Edge 89+, Opera 75+ и Firefox 151+; Safari не объявлял о поддержке, а API требует как безопасного контекста, так и пользовательского жеста для вызова requestPort().
  • Web Serial существует отдельно от WebUSB, поскольку операционная система захватывает USB-to-serial адаптеры через собственные драйверы раньше, чем WebUSB успевает к ним обратиться, — поэтому устройства, определяемые как COM-порты или узлы /dev/tty, требуют именно Web Serial.
  • Вызов reader.releaseLock() перед port.close() позволяет избежать ошибок «порт уже открыт», которые возникают при повторном подключении из-за незакрытых блокировок потока.
  • Начиная с Chrome 117, Web Serial умеет работать со сопряжёнными устройствами Bluetooth Classic RFCOMM/SPP через allowedBluetoothServiceClassIds и фильтры bluetoothServiceClassId — беспроводной последовательный интерфейс без отдельного вызова WebBluetooth.
  • В Chrome 130 добавлено свойство SerialPort.connected — булево значение для различения порта, физически присутствующего в системе, и порта, закрытого приложением.

Минимальный паттерн: подключение, чтение, запись, завершение

Любой проект на Web Serial начинается с одних и тех же четырёх шагов: запросить порт, открыть его с нужной скоростью передачи данных, читать или писать через поток, а затем завершить работу — освободить блокировку перед закрытием. Фрагмент кода ниже является базовым шаблоном, на который опирается вся остальная часть статьи. В нём используется необязательный аргумент filters для сужения списка портов в диалоге браузера по USB vendor ID и product ID — согласно определению SerialPortRequestOptions в спецификации WICG Web Serial, это исключает посторонние последовательные порты, так что пользователь видит только нужные устройства.

// Должно выполняться внутри обработчика пользовательского жеста (например, обработчика клика).
const port = await navigator.serial.requestPort({
  filters: [{ usbVendorId: 0x2341 }], // необязательно; опустите для выбора любого порта
});
await port.open({ baudRate: 115200 });

const reader = port.readable.getReader();
try {
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;          // reader был отменён
    console.log(value);       // value — это фрагмент Uint8Array
  }
} finally {
  reader.releaseLock();       // освободить ПЕРЕД закрытием
  await port.close();
}

Отсутствие вызова reader.releaseLock() перед port.close() — распространённая причина ошибок «порт уже открыт» при повторном подключении: блокировка удерживает читаемый поток, а close() завершается с ошибкой, пока поток заблокирован, — согласно шагам SerialPort.close() в спецификации. Для текстовых протоколов пропускайте потоки через TextEncoderStream и TextDecoderStream вместо ручного декодирования сырых фрагментов Uint8Array; подробности о кодировании описаны в руководстве MDN по Web Serial.

Примеры Web Serial API: шесть проектов для выходных

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

1. Браузерный монитор последовательного порта

Монитор последовательного порта — это инструмент отладки с интенсивным чтением, который заменяет Serial Monitor из Arduino IDE или screen /dev/ttyUSB0 вкладкой браузера. Он открывает порт, пропускает входящие байты через декодер и добавляет декодированные строки в DOM. Это самый простой проект из всех и лучший для начала: он задействует цикл чтения без сложностей протокола записи.

Аппаратное сопряжение и протокол: любое UART-устройство — Arduino, ESP32, USB-to-serial адаптер, — передающее текст с разделением по символу новой строки.

const port = await navigator.serial.requestPort();
await port.open({ baudRate: 9600 });

const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();

let buffer = "";
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  const lines = buffer.split("\n");
  buffer = lines.pop() ?? "";        // сохранить неполную последнюю строку
  lines.forEach(line => appendToLog(line));
}

Подводный камень: данные по последовательному порту приходят фрагментами произвольного размера, а не целыми строками — один вызов read() может вернуть половину строки или сразу три строки, поэтому накапливайте данные в буфере до появления разделителя, прежде чем выводить их.

2. Прошивальщик микропрограмм

Прошивальщик записывает скомпилированный бинарный файл во флеш-память микроконтроллера по последовательному порту — так же, как это делает инструмент типа esptool в браузере для чипов ESP32. Это проект с интенсивной записью и жёсткой привязкой к протоколу: прежде чем начать передачу данных, чип необходимо перевести в режим загрузчика, а сами данные оформляются в соответствии с протоколом прошивки конкретного чипа.

Аппаратное сопряжение и протокол: ESP32/ESP8266 через последовательный протокол загрузчика с SLIP-фреймингом; вход в режим загрузчика управляется управляющими сигналами DTR и RTS.

const port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });

// Переключить управляющие сигналы для перевода ESP в режим загрузки.
// См. документированную последовательность сигналов boot-mode от Espressif.
await port.setSignals({ dataTerminalReady: false, requestToSend: true });
await port.setSignals({ dataTerminalReady: true, requestToSend: false });
// ...теперь отправить фреймированные команды загрузчика через port.writable

Браузерные прошивальщики в стиле ESPTool должны переключать управляющие сигналы DTR и RTS в строго определённой последовательности, чтобы перевести чип в режим загрузки до отправки каких-либо команд записи; Espressif документирует поведение сигналов boot-mode, а Web Serial предоставляет доступ к этим переключателям через SerialPort.setSignals().

Подводный камень: если пропустить последовательность DTR/RTS для входа в загрузчик, чип останется в прикладной прошивке вместо перехода в режим последовательного загрузчика, и прошивка не сможет выполниться корректно.

3. Стример G-code заданий

Стример G-code отправляет задание на печать или резку на 3D-принтер или ЧПУ-станок по одной строке за раз, ожидая подтверждения от прошивки после каждой строки перед отправкой следующей. Это длительный цикл записи с управлением потоком, что делает его значительно более сложным проектом по сравнению с простым отправителем без обратной связи.

Аппаратное сопряжение и протокол: прошивка Marlin или GRBL через USB-последовательный порт, обменивающаяся строчным G-code с подтверждениями ok.

const encoder = new TextEncoderStream();
encoder.readable.pipeTo(port.writable);
const writer = encoder.writable.getWriter();

const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();

for (const line of gcodeLines) {
  await writer.write(line + "\n");
  // Ждать, пока прошивка не сообщит об обработке строки.
  let ack = "";
  while (!ack.includes("ok")) {
    const { value } = await reader.read();
    ack += value;
  }
}

Стримеры G-code должны ожидать подтверждения ok от принтера после каждой строки, прежде чем отправлять следующую; справочник G-code RepRap описывает это рукопожатие, а переполнение последовательного буфера без него приводит к переполнению очереди команд прошивки в середине задания.

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

4. Живая телеметрическая панель

Телеметрическая панель считывает непрерывный поток данных с датчиков через последовательный порт и отображает их в виде живого графика в браузере — температуру, напряжение, оси акселерометра, координаты GPS. Часть, относящаяся к Web Serial, — это просто цикл чтения; ценность заключается в том, что декодированные значения напрямую передаются в библиотеку построения графиков, работающую в той же вкладке.

Аппаратное сопряжение и протокол: любой микроконтроллер с датчиками, передающий строки в формате CSV, или GPS-модуль, передающий предложения NMEA 0183.

const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();

let buffer = "";
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  let nl;
  while ((nl = buffer.indexOf("\n")) >= 0) {
    const [t, v] = buffer.slice(0, nl).split(",");
    pushSample(Number(t), Number(v));   // передать данные в график
    buffer = buffer.slice(nl + 1);
  }
}

Подводный камень: быстрый датчик может генерировать строки быстрее, чем успевает перерисоваться график; накапливайте выборки и обновляйте отображение через requestAnimationFrame, а не перерисовывайте при каждом вызове read(), иначе основной поток будет заблокирован.

5. Контроллер дисплея или визуальных эффектов

Контроллер дисплея рендерит пиксели или кадры в браузере и передаёт их на физический дисплей — светодиодную матрицу, OLED или электронные чернила. Здесь браузер выступает движком рендеринга: вы вычисляете битмап или массив яркости на JavaScript и записываете байты в формате, который ожидает контроллер дисплея.

Аппаратное сопряжение и протокол: светодиодная матрица на MAX7219, OLED на SSD1306 или модуль электронных чернил за микроконтроллером, принимающим байты фреймбуфера по UART.

const writer = port.writable.getWriter();

// Кадр 8x8 — один байт на строку (установленный бит = светодиод включён).
function renderFrame(rows: number[]) {
  return writer.write(Uint8Array.from(rows));
}

await renderFrame([
  0b00111100, 0b01000010, 0b10100101, 0b10000001,
  0b10100101, 0b10011001, 0b01000010, 0b00111100,
]);

Подводный камень: запись не синхронизируется автоматически с частотой обновления дисплея, поэтому анимация из браузера может приводить к разрывам или потере кадров; отправляйте полный кадр за одно обновление и позвольте микроконтроллеру самостоятельно управлять таймингом дисплея, не передавая частичные строки.

6. Интерфейс настройки устройства

Интерфейс настройки устройства позволяет читать и записывать параметры подключённого по последовательному порту устройства — параметры полётного контроллера дрона, каналы памяти радиостанции, значения регистров IoT-модуля. Именно здесь фильтрация по VID/PID оправдывает себя в полной мере: приложения для настройки ориентированы на конкретное устройство, поэтому фильтрация диалога выбора порта по vendor ID и product ID означает, что пользователь видит только своё устройство, а не все COM-порты на машине.

Аппаратное сопряжение и протокол: полётный контроллер, использующий протокол MSP, протокол CAT/CI-V радиостанции или любой модуль с документированным набором команд доступа к регистрам — выбирается с помощью фильтров requestPort.

// Сузить список до одного известного устройства по vendor/product ID.
const port = await navigator.serial.requestPort({
  filters: [{ usbVendorId: 0x10c4, usbProductId: 0xea60 }], // пример CP210x
});
await port.open({ baudRate: 115200 });

const writer = port.writable.getWriter();
await writer.write(buildReadConfigCommand());   // запросить текущие настройки
// ...прочитать ответ, заполнить поля формы, записать изменения при сохранении

Передача filters: [{ usbVendorId, usbProductId }] в requestPort() сужает диалог до совпадающих портов согласно спецификации SerialPortRequestOptions. Чтобы полностью пропустить диалог при повторном визите, navigator.serial.getPorts() возвращает порты, которые пользователь уже одобрил ранее.

Подводный камень: vendor ID и product ID в диалоге выбора относятся к чипу USB-to-serial моста (FTDI, CP210x, CH340), а не к конечному устройству, поэтому несколько не связанных между собой продуктов могут иметь одинаковый usbVendorId — по возможности фильтруйте одновременно по usbVendorId и usbProductId.

Когда Web Serial — правильный выбор, а когда лучше использовать альтернативы

Используйте Web Serial, когда устройство определяется как COM-порт или узел /dev/tty, то есть когда операционная система уже загрузила для него драйвер последовательного порта. Web Serial существует отдельно от WebUSB, поскольку драйверы ОС захватывают USB-to-serial адаптеры раньше, чем WebUSB успевает к ним обратиться, — эту проблему напрямую описывает WICG Web Serial explainer. Для других классов устройств следует использовать другой API.

APIТип устройстваМодель разрешенийПоддержка браузеровЛучше всего подходит для
Web SerialУстройства на последовательном/COM/tty портуДиалог с пользовательским жестомChrome/Edge 89+, Opera 75+, Firefox 151+Микроконтроллеры, принтеры, USB-serial адаптеры
WebUSBПрямые USB-интерфейсы (без драйвера ОС)Диалог с пользовательским жестомНа основе ChromiumКастомные USB-устройства без последовательного драйвера
WebHIDУстройства пользовательского интерфейсаДиалог с пользовательским жестомНа основе ChromiumГеймпады, клавиатуры, кастомные HID-периферийные устройства
Web BluetoothBluetooth Low Energy (GATT)Диалог с пользовательским жестомНа основе ChromiumBLE-датчики, маяки, носимые устройства
WebSocket + серверный демонЛюбые устройстваЧерез серверВсе браузерыШирокий охват браузеров, серверный разбор данных

Если вам нужна поддержка пользователей Firefox и Safari сегодня, а логика работы с устройством может располагаться на стороне сервера, небольшой нативный демон, предоставляющий WebSocket, является универсальным запасным вариантом — ценой шага установки, которого Web Serial позволяет избежать.

Недавние дополнения, заслуживающие внимания

В Chrome 117 в Web Serial добавлена поддержка Bluetooth Classic RFCOMM/SPP, а в Chrome 130 — булево свойство SerialPort.connected для различения физически присутствующих портов и портов, закрытых приложением. Оба нововведения отсутствуют в большинстве существующих руководств, поэтому стоит учитывать их при разработке.

Начиная с Chrome 117 на десктопе, Web Serial может взаимодействовать со сопряжёнными устройствами Bluetooth Classic RFCOMM/SPP: передайте allowedBluetoothServiceClassIds (или запись filters: [{ bluetoothServiceClassId }]), чтобы кастомные RFCOMM-сервисы появились в диалоге requestPort(), а идентификатор класса сервиса можно прочитать через port.getInfo().bluetoothServiceClassId — без отдельного вызова WebBluetooth. Этот Bluetooth-путь доступен только в браузерах на основе Chromium (Chrome/Edge 117+, Opera 103+) и по-прежнему помечен как экспериментальный в MDN; общая поддержка Web Serial в Firefox 151 пока не включает опции для Bluetooth-классов сервисов. Подробности описаны в публикации Chrome Serial over Bluetooth on the web. Это превращает беспроводной последовательный интерфейс в тот же поток чтения/записи, который вы уже написали для USB.

В Chrome 130 добавлено SerialPort.connected — булево значение, равное true, когда порт физически присутствует в системе, но не обязательно открыт. Оно позволяет UX переподключения различать «устройство отключено» и «порт закрыт приложением» — используйте его совместно с событиями connect и disconnect для отображения живого индикатора соединения без опроса состояния.

requestPort() завершается с ошибкой без видимого эффекта, когда пользователь закрывает диалог разрешений Web Serial, и большинство реализаций не отображают этот отказ как видимое состояние — кнопка подключения просто возвращается к исходному виду, и страница выглядит так, словно ничего не произошло. Этот единственный сценарий сбоя UX характерен для всех шести категорий проектов, описанных выше. Записи сессий пользователей в потоках подключения Web Serial нередко демонстрируют характерную картину: пользователи нажимают кнопку подключения два-три раза подряд, а затем уходят — верный признак того, что отказ не сообщается. Перехватывайте ошибку requestPort() и показывайте явное сообщение «устройство не выбрано» или «порт занят»; порты, удерживаемые другим процессом (Arduino IDE, screen или ModemManager), дают сбой столь же незаметно.

Выберите одну категорию, подключите минимальный блок connect-read-write-cleanup из начала статьи и убедитесь, что матрица поддержки соответствует вашей аудитории, прежде чем посвящать этому целые выходные. Самый быстрый путь к рабочей реализации — монитор последовательного порта для платы, которая уже есть у вас под рукой: как только цикл чтения и завершение работы отлажены, остальные пять категорий — это вариации на тему того же байтового потока. Однако Web Serial — неподходящий инструмент, если ваша аудитория включает пользователей Safari, если устройство не определяется как COM-порт или узел /dev/tty (в этом случае обратитесь к WebUSB, WebHID или Web Bluetooth), или если логика разбора данных по существу должна находиться на стороне сервера — в любом из этих случаев небольшой нативный демон за WebSocket обеспечит более широкий охват ценой шага установки.

Часто задаваемые вопросы

Закрытие диалога разрешений приводит к отклонению промиса requestPort() вместо его разрешения, и большинство реализаций никак не отображают этот отказ как видимое состояние, поэтому кнопка молча возвращается к исходному виду. Оберните вызов requestPort() в блок try/catch и показывайте явное сообщение «устройство не выбрано» при отказе. Порты, уже занятые другим процессом — например, Arduino IDE, screen или ModemManager, — дают сбой столь же незаметно.

Для Arduino используйте Web Serial, поскольку операционная система загружает драйвер последовательного порта, который захватывает USB-to-serial адаптер, определяя плату как COM-порт или узел /dev/tty, недоступный для WebUSB. WebUSB предназначен для прямых USB-интерфейсов без драйвера ОС, например для кастомных USB-устройств. Если устройство отображается как COM-порт, ему нужен Web Serial, а не WebUSB.

Да. Вызовите navigator.serial.getPorts(), чтобы получить массив портов, уже одобренных пользователем в предыдущих сессиях, а затем откройте нужный напрямую без запроса. Это полностью пропускает диалог выбора при повторных визитах. В Chrome 130 и выше SerialPort.connected возвращает булево значение, указывающее, физически ли присутствует порт в системе, что позволяет UX переподключения различать отключённое устройство и порт, просто закрытый приложением.

Web Serial работает на десктопе в Chrome 89+, Edge 89+, Opera 75+ и Firefox 151+ согласно данным о совместимости браузеров MDN. Safari не объявлял о поддержке и не публиковал дорожную карту. API также требует безопасного контекста — HTTPS или localhost — и пользовательского жеста для вызова requestPort(). Если вам нужна широкая поддержка Firefox и Safari сегодня, нативный демон, предоставляющий WebSocket, является универсальным запасным вариантом ценой шага установки.

Ошибка «порт уже открыт» при повторном подключении почти всегда означает утечку блокировки потока. Если вызвать port.close() пока reader удерживает читаемый поток, закрытие завершится с ошибкой, поскольку поток заблокирован. Всегда вызывайте reader.releaseLock() перед port.close(), желательно внутри блока finally, чтобы блокировка снималась независимо от того, как завершился цикл чтения. То же самое относится к writer, полученному из port.writable.

Gain control over your UX

See how users are using your site as if you were sitting next to them, learn and iterate faster with OpenReplay. — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay