Ler e gravar em uma porta serial

A API Web Serial permite que os sites se comuniquem com dispositivos seriais.

François Beaufort
François Beaufort

O que é a API Web Serial?

Uma porta serial é uma interface de comunicação bidirecional que permite enviar e receber dados byte por byte.

A API Web Serial oferece uma maneira de os sites lerem e gravarem em um dispositivo serial com JavaScript. Os dispositivos seriais são conectados por uma porta serial no sistema do usuário ou por dispositivos USB e Bluetooth removíveis que emulam uma porta serial.

Em outras palavras, a API Web Serial conecta a Web e o mundo físico, permitindo que os sites se comuniquem com dispositivos seriais, como microcontroladores e impressoras 3D.

Essa API também é um ótimo complemento para WebUSB (link em inglês), já que os sistemas operacionais exigem que os aplicativos se comuniquem com algumas portas seriais usando a API serial de nível mais alto em vez da API USB de baixo nível.

Casos de uso sugeridos

Nos setores educacional, amadores e industriais, os usuários conectam dispositivos periféricos aos computadores. Esses dispositivos geralmente são controlados por microcontroladores via uma conexão serial usada por software personalizado. Alguns softwares personalizados para controlar esses dispositivos são criados com tecnologia da Web:

Em alguns casos, os sites se comunicam com o dispositivo por um aplicativo agente que os usuários instalaram manualmente. Em outros, o aplicativo é entregue em um aplicativo empacotado por meio de um framework como o Electron. E, em outros, o usuário precisa realizar uma etapa adicional, como copiar um aplicativo compilado para o dispositivo por um pen drive USB.

Em todos esses casos, a experiência do usuário é aprimorada com comunicação direta entre o site e o dispositivo que ele está controlando.

Status atual

Step Status
1. Criar explicação Concluído
2. Criar rascunho inicial da especificação Concluído
3. Reunir feedbacks e iterar no design Concluído
4. Teste de origem Concluído
5. lançamento Concluído

Como usar a API Web Serial

Detecção de recursos

Para verificar se a API Web Serial é compatível, use:

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

Abrir uma porta serial

A API Web Serial é assíncrona por padrão. Isso impede que a interface do site seja bloqueada ao aguardar entrada, o que é importante porque dados seriais podem ser recebidos a qualquer momento, exigindo uma maneira de detectá-los.

Para abrir uma porta serial, primeiro acesse um objeto SerialPort. Para isso, é possível solicitar que o usuário selecione uma única porta serial chamando navigator.serial.requestPort() em resposta a um gesto do usuário, como toque ou clique do mouse, ou escolher uma em navigator.serial.getPorts(), que retorna uma lista de portas seriais a que o site recebeu acesso.

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

A função navigator.serial.requestPort() usa um literal de objeto opcional que define filtros. Eles são usados para corresponder a qualquer dispositivo serial conectado via USB com um fornecedor USB obrigatório (usbVendorId) e identificadores de produto USB opcionais (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();
Captura de tela de um prompt de porta serial em um site
Prompt do usuário para selecionar um micro:bit da BBC

Chamar requestPort() solicita que o usuário selecione um dispositivo e retorne um objeto SerialPort. Quando você tiver um objeto SerialPort, chamar port.open() com a taxa de Baud desejada abrirá a porta serial. O membro do dicionário baudRate especifica a rapidez com que os dados são enviados em uma linha serial. Ele é expresso em unidades de bits por segundo (bps). Verifique a documentação do seu dispositivo para saber o valor correto, já que todos os dados enviados e recebidos serão sem sentido se forem especificados incorretamente. Para alguns dispositivos USB e Bluetooth que emulam uma porta serial, esse valor pode ser definido com segurança como qualquer valor, já que é ignorado pela emulação.

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

Também é possível especificar qualquer uma das opções abaixo ao abrir uma porta serial. Essas opções são opcionais e têm valores padrão convenientes.

  • dataBits: o número de bits de dados por frame (7 ou 8).
  • stopBits: o número de bits de parada no final de um frame (1 ou 2).
  • parity: o modo de paridade ("none", "even" ou "odd").
  • bufferSize: o tamanho dos buffers de leitura e gravação que precisam ser criados (precisa ser menor que 16 MB).
  • flowControl: o modo de controle de fluxo ("none" ou "hardware").

Ler em uma porta serial

Os streams de entrada e saída na API Web Serial são processados pela API Streams.

Depois que a conexão da porta serial for estabelecida, as propriedades readable e writable do objeto SerialPort retornam um ReadableStream e um WritableStream. Eles serão usados para receber e enviar dados ao dispositivo serial. Ambos usam instâncias Uint8Array para transferência de dados.

Quando novos dados chegam do dispositivo serial, port.readable.getReader().read() retorna duas propriedades de maneira assíncrona: o value e um booleano done. Se done for verdadeiro, a porta serial foi fechada ou não há mais dados sendo recebidos. Chamar port.readable.getReader() cria um leitor e bloqueia readable. Enquanto readable estiver bloqueado, a porta serial não poderá ser fechada.

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

Alguns erros de leitura de porta serial não fatais podem acontecer em algumas condições, como overflow do buffer, erros de enquadramento ou erros de paridade. Elas são geradas como exceções e podem ser capturadas adicionando outra repetição sobre a anterior que verifica port.readable. Isso funciona porque, desde que os erros não sejam fatais, um novo ReadableStream é criado automaticamente. Se ocorrer um erro fatal, como o dispositivo serial que está sendo removido, port.readable se tornará nulo.

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

Se o dispositivo serial enviar texto de volta, você poderá canalizar port.readable para TextDecoderStream, conforme mostrado abaixo. Um TextDecoderStream é um fluxo de transformação que pega todos os fragmentos Uint8Array e os converte em strings.

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

Você pode assumir o controle de como a memória é alocada ao ler o stream com um leitor "traga seu próprio buffer". Chame port.readable.getReader({ mode: "byob" }) para acessar a interface ReadableStreamBYOBReader e forneça seu próprio ArrayBuffer ao chamar read(). A API Web Serial é compatível com esse recurso no Chrome 106 ou mais recente.

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

Confira um exemplo de como reutilizar o buffer de 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`.
}

Veja aqui outro exemplo de como ler uma quantidade específica de dados de uma porta serial:

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

Gravar em uma porta serial

Para enviar dados a um dispositivo serial, transmita dados para port.writable.getWriter().write(). É necessário chamar releaseLock() em port.writable.getWriter() para que a porta serial seja fechada mais tarde.

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

Envie texto para o dispositivo por meio de um TextEncoderStream encadeado para port.writable, conforme mostrado abaixo.

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

Fechar uma porta serial

port.close() fecha a porta serial se os membros readable e writable forem desbloqueados, o que significa que releaseLock() foi chamado para seus respectivos leitores e gravadores.

await port.close();

No entanto, ao ler dados continuamente de um dispositivo serial usando um loop, o port.readable sempre será bloqueado até encontrar um erro. Nesse caso, chamar reader.cancel() forçará reader.read() a ser resolvido imediatamente com { value: undefined, done: true } e, portanto, permitirá que o loop chame 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;
});

Fechar uma porta serial é mais complicado ao usar fluxos de transformação. Chame reader.cancel() como antes. Em seguida, chame writer.close() e port.close(). Isso propaga erros pelos streams de transformação para a porta serial subjacente. Como a propagação de erros não acontece imediatamente, é necessário usar as promessas readableStreamClosed e writableStreamClosed criadas anteriormente para detectar quando port.readable e port.writable foram desbloqueados. Cancelar o reader faz com que o stream seja cancelado. É por isso que você precisa capturar e ignorar o erro resultante.

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

Ouvir conexão e desconexão

Se uma porta serial for fornecida por um dispositivo USB, esse dispositivo poderá estar conectado ou desconectado do sistema. Quando o site recebe permissão para acessar uma porta serial, ele precisa monitorar os eventos connect e 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.
});

Processar indicadores

Depois de estabelecer a conexão da porta serial, é possível consultar e definir explicitamente os sinais expostos pela porta serial para detecção de dispositivos e controle de fluxo. Esses indicadores são definidos como valores booleanos. Por exemplo, alguns dispositivos, como o Arduino, entrarão em um modo de programação se o sinal "Data Terminal Ready" (DTR) for alternado.

A definição de sinais de saída e o recebimento de sinais de entrada são feitos, respectivamente, chamando port.setSignals() e port.getSignals(). Confira exemplos de uso abaixo.

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

Como transformar streams

Quando você recebe dados do dispositivo serial, nem sempre consegue todos de uma vez. Eles podem ser divididos arbitrariamente. Para mais informações, consulte Conceitos da API Streams.

Para lidar com isso, use alguns streams de transformação integrados, como TextDecoderStream, ou crie seu próprio stream de transformação, que permite analisar o fluxo de entrada e retornar dados analisados. O stream de transformação fica entre o dispositivo serial e o loop de leitura que está consumindo o stream. Ele pode aplicar uma transformação arbitrária antes que os dados sejam consumidos. Pense nisso como uma linha de montagem: à medida que um widget desce na linha, cada etapa modifica o widget, de modo que, quando chegar ao destino final, ele se torna totalmente funcionando.

Foto de uma fábrica de aviões
Fábrica de Aeroplane do Bromwich do Castelo da Segunda Guerra Mundial

Por exemplo, considere como criar uma classe de stream de transformação que consuma um stream e o divida com base em quebras de linha. O método transform() é chamado sempre que novos dados são recebidos pelo stream. Ele pode enfileirar os dados ou salvá-los para mais tarde. O método flush() é chamado quando o stream é fechado e processa todos os dados que ainda não foram processados.

Para usar a classe de fluxo de transformação, é necessário canalizar um fluxo de entrada por ela. No terceiro exemplo de código em Ler em uma porta serial, o fluxo de entrada original foi canalizado somente por uma TextDecoderStream. Portanto, precisamos chamar pipeThrough() para canalizar nosso novo 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();

Para depurar problemas de comunicação de dispositivos seriais, use o método tee() de port.readable para dividir os fluxos de entrada e saída do dispositivo serial. Os dois fluxos criados podem ser consumidos de forma independente, e isso permite imprimir um no console para inspeção.

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.

Revogar acesso a uma porta serial

O site pode limpar as permissões para acessar uma porta serial que não está mais interessada em reter, chamando forget() na instância SerialPort. Por exemplo, para um aplicativo da Web educacional usado em um computador compartilhado com muitos dispositivos, um grande número de permissões acumuladas geradas pelo usuário cria uma experiência insatisfatória.

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

Como forget() está disponível no Chrome 103 ou mais recente, confira se esse recurso tem suporte ao seguinte:

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

Dicas para desenvolvedores

A depuração da API Web Serial no Chrome é fácil com a página interna, about://device-log, em que você pode ver todos os eventos relacionados a dispositivos seriais em um único lugar.

Captura de tela da página interna de depuração da API Web Serial.
Página interna do Chrome para depurar a API Web Serial.

Codelab

No codelab do Google Developers, você usará a API Web Serial para interagir com uma placa BBC micro:bit para mostrar imagens na matriz de LED 5x5.

Suporte ao navegador

A API Web Serial está disponível em todas as plataformas de computador (ChromeOS, Linux, macOS e Windows) no Chrome 89.

Plástico poligonal

No Android, o suporte para portas seriais USB é possível usando a API WebUSB e o polyfill da API Serial. Esse polyfill é limitado a hardware e plataformas em que o dispositivo pode ser acessado pela API WebUSB, porque ele não foi reivindicado por um driver de dispositivo integrado.

Segurança e privacidade

Os autores das especificações projetaram e implementaram a API Web Serial usando os princípios fundamentais definidos em Como controlar o acesso a recursos avançados da plataforma Web, incluindo controle do usuário, transparência e ergonomia. A capacidade de usar essa API é controlada principalmente por um modelo de permissão que concede acesso a apenas um dispositivo serial por vez. Em resposta a uma solicitação, o usuário precisa seguir etapas ativas para selecionar um dispositivo serial específico.

Para entender as compensações de segurança, confira as seções de segurança e privacidade da explicação da API Web Serial.

Feedback

A equipe do Chrome adoraria saber sua opinião e saber mais sobre sua experiência com a API Web Serial.

Fale sobre o design da API

Há algo na API que não funciona como esperado? Ou há métodos ou propriedades ausentes que você precisa para implementar sua ideia?

Registre um problema de especificação no repositório da API Web Serial do GitHub (link em inglês) ou adicione suas opiniões a um problema atual.

Informar um problema com a implementação

Você encontrou um bug na implementação do Chrome? Ou a implementação é diferente da especificação?

Registre um bug em https://new.crbug.com. Inclua o máximo de detalhes possível, forneça instruções simples para reproduzir o bug e defina Componentes como Blink>Serial. O Glitch funciona muito bem para compartilhar réplicas rápidas e fáceis.

Mostrar apoio

Você planeja usar a API Web Serial? Seu suporte público ajuda a equipe do Chrome a priorizar recursos e mostra aos outros fornecedores de navegador a importância do suporte a eles.

Envie um tweet para @ChromiumDev com a hashtag #SerialAPI e conte para a gente onde e como você está usando a hashtag.

Links úteis

Demonstrações

Agradecimentos

Agradecemos a Reilly Grant e Joe Medley pelas análises deste artigo. Foto da fábrica de um avião por Birmingham Museums Trust no Unsplash.