Leer y escribir en un puerto en serie

La API de Web Serial permite que los sitios web se comuniquen con dispositivos seriales.

François Beaufort
François Beaufort

¿Qué es la API de Web Serial?

Un puerto serie es una interfaz de comunicación bidireccional que permite enviar y recibir datos byte por byte.

La API de Web Serial proporciona a los sitios web una forma de leer y escribir en un dispositivo en serie con JavaScript. Los dispositivos serie se conectan a través de un puerto serie en el sistema del usuario o a través de dispositivos USB y Bluetooth extraíbles que emulan un puerto serie.

En otras palabras, la API de Web Serial une la Web y el mundo físico, ya que permite que los sitios web se comuniquen con dispositivos seriales, como microcontroladores y impresoras 3D.

Esta API también es un excelente complemento para WebUSB, ya que los sistemas operativos requieren que las aplicaciones se comuniquen con algunos puertos en serie a través de su API en serie de nivel superior en lugar de la API de USB de bajo nivel.

Casos de uso sugeridos

En los sectores educativo, de aficionados y de la industria, los usuarios conectan dispositivos periféricos a sus computadoras. Estos dispositivos suelen estar controlados por microcontroladores a través de una conexión serie que usa software personalizado. Algunos software personalizados para controlar estos dispositivos se compilan con tecnología web:

En algunos casos, los sitios web se comunican con el dispositivo a través de una aplicación de agente que los usuarios instalaron de forma manual. En otros, la aplicación se entrega en una aplicación empaquetada a través de un framework como Electron. En otros, el usuario debe realizar un paso adicional, como copiar una aplicación compilada en el dispositivo a través de una unidad de memoria flash USB.

En todos estos casos, la experiencia del usuario mejorará, ya que se proporcionará una comunicación directa entre el sitio web y el dispositivo que controla.

Estado actual

Paso Estado
1. Crea una explicación Completar
2. Crea un borrador inicial de la especificación Completar
3. Recopila comentarios y itera en el diseño Completar
4. Prueba de origen Completar
5. Lanzamiento Completar

Usa la API de Web Serial

Detección de atributos

Para verificar si la API de Web Serial es compatible, usa lo siguiente:

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

Cómo abrir un puerto en serie

La API de Web Serial es asíncrona de forma predeterminada. Esto evita que la IU del sitio web se bloquee cuando espera una entrada, lo que es importante porque los datos en serie se pueden recibir en cualquier momento y requieren una forma de escucharlos.

Para abrir un puerto serie, primero accede a un objeto SerialPort. Para ello, puedes pedirle al usuario que seleccione un solo puerto serie llamando a navigator.serial.requestPort() en respuesta a un gesto del usuario, como un toque o un clic del mouse, o elegir uno de navigator.serial.getPorts(), que muestra una lista de puertos serie a los que se le otorgó acceso al sitio web.

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

La función navigator.serial.requestPort() toma un literal de objeto opcional que define los filtros. Estos se usan para hacer coincidir cualquier dispositivo en serie conectado por USB con un proveedor de USB obligatorio (usbVendorId) y los identificadores opcionales de producto 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();
Captura de pantalla de un mensaje de puerto serie en un sitio web
Mensaje del usuario para seleccionar un BBC micro:bit

Si llamas a requestPort(), se le solicita al usuario que seleccione un dispositivo y se muestra un objeto SerialPort. Una vez que tengas un objeto SerialPort, llamar a port.open() con la tasa de baudios deseada abrirá el puerto serie. El miembro del diccionario baudRate especifica la rapidez con la que se envían los datos a través de una línea serie. Se expresa en unidades de bits por segundo (bps). Consulta la documentación de tu dispositivo para obtener el valor correcto, ya que todos los datos que envíes y recibas serán incoherentes si se especifican de forma incorrecta. En el caso de algunos dispositivos USB y Bluetooth que emulan un puerto serie, este valor se puede establecer de forma segura en cualquier valor, ya que la emulación lo ignora.

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

También puedes especificar cualquiera de las siguientes opciones cuando abras un puerto en serie. Estas opciones son opcionales y tienen valores predeterminados convenientes.

  • dataBits: Es la cantidad de bits de datos por fotograma (7 u 8).
  • stopBits: Es la cantidad de bits de parada al final de una trama (1 o 2).
  • parity: Es el modo de paridad ("none", "even" o "odd").
  • bufferSize: Es el tamaño de los búferes de lectura y escritura que se deben crear (debe ser inferior a 16 MB).
  • flowControl: Es el modo de control de flujo ("none" o "hardware").

Lee desde un puerto en serie

La API de Streams controla las transmisiones de entrada y salida en la API de Web Serial.

Una vez que se establece la conexión del puerto en serie, las propiedades readable y writable del objeto SerialPort muestran un ReadableStream y un WritableStream. Se usarán para recibir y enviar datos al dispositivo serie. Ambos usan instancias de Uint8Array para la transferencia de datos.

Cuando llegan datos nuevos del dispositivo en serie, port.readable.getReader().read() muestra dos propiedades de forma asíncrona: value y un valor booleano done. Si done es verdadero, el puerto serie se cerró o no hay más datos entrantes. Llamar a port.readable.getReader() crea un lector y lo bloquea a readable. Mientras readable esté bloqueado, no se podrá cerrar el puerto serie.

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

Algunos errores de lectura de puerto en serie recuperables pueden ocurrir en determinadas condiciones, como el desbordamiento del búfer, los errores de enmarcado o los errores de paridad. Se arrojan como excepciones y se pueden detectar agregando otro bucle sobre el anterior que verifique port.readable. Esto funciona porque, siempre que los errores no sean fatales, se crea automáticamente un nuevo ReadableStream. Si se produce un error grave, como la eliminación del dispositivo en serie, port.readable se vuelve 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.
  }
}

Si el dispositivo serie envía texto, puedes canalizar port.readable a través de un TextDecoderStream, como se muestra a continuación. Una TextDecoderStream es una transmisión de transformación que toma todos los fragmentos de Uint8Array y los convierte en 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);
}

Puedes controlar cómo se asigna la memoria cuando lees desde la transmisión con un lector de "trae tu propio búfer". Llama a port.readable.getReader({ mode: "byob" }) para obtener la interfaz ReadableStreamBYOBReader y proporciona tu propio ArrayBuffer cuando llames a read(). Ten en cuenta que la API de Web Serial admite esta función en Chrome 106 o versiones posteriores.

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

Este es un ejemplo de cómo volver a usar el búfer 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`.
}

Este es otro ejemplo de cómo leer una cantidad específica de datos de un puerto serie:

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

Cómo escribir en un puerto en serie

Para enviar datos a un dispositivo en serie, pasa los datos a port.writable.getWriter().write(). Llamar a releaseLock() en port.writable.getWriter() es obligatorio para que el puerto serie se cierre más adelante.

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

Envía texto al dispositivo a través de un TextEncoderStream canalizado a port.writable, como se muestra a continuación.

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

Cómo cerrar un puerto en serie

port.close() cierra el puerto serie si sus miembros readable y writable están desbloqueados, lo que significa que se llamó a releaseLock() para su respectivo lector y escritor.

await port.close();

Sin embargo, cuando se leen datos de forma continua desde un dispositivo serie con un bucle, port.readable siempre estará bloqueado hasta que encuentre un error. En este caso, llamar a reader.cancel() forzará a reader.read() a resolverse de inmediato con { value: undefined, done: true } y, por lo tanto, permitirá que el bucle llame a 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;
});

Cerrar un puerto en serie es más complicado cuando se usan transmisiones de transformación. Llama a reader.cancel() como lo hiciste antes. Luego, llama a writer.close() y port.close(). Esto propaga errores a través de las transmisiones de transformación al puerto en serie subyacente. Debido a que la propagación de errores no ocurre de inmediato, debes usar las promesas readableStreamClosed y writableStreamClosed creadas antes para detectar cuándo se desbloquearon port.readable y port.writable. Cancelar reader provoca que se anule la transmisión. Por este motivo, debes capturar el error resultante y, luego, ignorarlo.

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

Escucha la conexión y la desconexión

Si un dispositivo USB proporciona un puerto en serie, ese dispositivo puede conectarse o desconectarse del sistema. Cuando se le otorga permiso al sitio web para acceder a un puerto serie, debe supervisar los eventos connect y 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.
});

Controla los indicadores

Después de establecer la conexión del puerto serie, puedes consultar y configurar de forma explícita los indicadores que expone el puerto serie para la detección de dispositivos y el control de flujo. Estos indicadores se definen como valores booleanos. Por ejemplo, algunos dispositivos, como Arduino, entrarán en modo de programación si se activa la señal de terminal de datos lista (DTR).

Para configurar indicadores de salida y obtener indicadores de entrada, llama a port.setSignals() y port.getSignals(), respectivamente. Consulta los ejemplos de uso que aparecen a continuación.

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

Transformación de transmisiones

Cuando recibes datos del dispositivo en serie, no necesariamente obtienes todos los datos a la vez. Se puede fragmentar de forma arbitraria. Para obtener más información, consulta Conceptos de la API de Streams.

Para lidiar con esto, puedes usar algunas transmisiones de transformación integradas, como TextDecoderStream, o crear tu propia transmisión de transformación que te permita analizar la transmisión entrante y mostrar los datos analizados. La transmisión de transformación se encuentra entre el dispositivo en serie y el bucle de lectura que la consume. Puede aplicar una transformación arbitraria antes de que se consuman los datos. Considéralo como una línea de montaje: a medida que un widget desciende en la línea, cada paso en la línea modifica el widget de modo que, cuando llega a su destino final, sea un widget completamente funcional.

Foto de una fábrica de aviones
Fábrica de Aeroplanos de Castle Bromwich de la Segunda Guerra Mundial

Por ejemplo, considera cómo crear una clase de flujo de transformación que consuma un flujo y lo divida en fragmentos según los saltos de línea. Se llama a su método transform() cada vez que el flujo recibe datos nuevos. Puede poner los datos en cola o guardarlos para más adelante. Se llama al método flush() cuando se cierra la transmisión y se controla cualquier dato que aún no se haya procesado.

Para usar la clase de flujo de transformación, debes canalizar un flujo entrante a través de ella. En el tercer ejemplo de código de Cómo leer desde un puerto serie, el flujo de entrada original solo se canalizaba a través de un TextDecoderStream, por lo que debemos llamar a pipeThrough() para canalizarlo a través de nuestro nuevo 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 comunicación en dispositivos en serie, usa el método tee() de port.readable para dividir las transmisiones desde o hacia el dispositivo en serie. Las dos transmisiones creadas se pueden consumir de forma independiente, lo que te permite imprimir una en la consola para su inspección.

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.

Revoca el acceso a un puerto en serie

El sitio web puede limpiar los permisos de acceso a un puerto en serie que ya no le interesa retener llamando a forget() en la instancia de SerialPort. Por ejemplo, en el caso de una aplicación web educativa que se usa en una computadora compartida con muchos dispositivos, una gran cantidad de permisos generados por el usuario acumulados crea una experiencia del usuario deficiente.

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

Como forget() está disponible en Chrome 103 o versiones posteriores, verifica si esta función es compatible con lo siguiente:

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

Sugerencias para desarrolladores

La depuración de la API de Web Serial en Chrome es fácil con la página interna, about://device-log, en la que puedes ver todos los eventos relacionados con el dispositivo serial en un solo lugar.

Captura de pantalla de la página interna para depurar la API de Web Serial.
Página interna de Chrome para depurar la API de Serial web.

Codelab

En el codelab de Google Developer, usarás la API de Web Serial para interactuar con una placa BBC micro:bit y mostrar imágenes en su matriz LED de 5 × 5.

Navegadores compatibles

La API de Web Serial está disponible en todas las plataformas de computadoras (ChromeOS, Linux, macOS y Windows) en Chrome 89.

Polyfill

En Android, la compatibilidad con puertos en serie basados en USB es posible mediante la API de WebUSB y el polyfill de la API de Serial. Este polyfill se limita al hardware y a las plataformas a las que se puede acceder a través de la API de WebUSB porque no se reclamó con un controlador de dispositivo integrado.

Seguridad y privacidad

Los autores de las especificaciones diseñaron e implementaron la API de Web Serial con los principios básicos definidos en Controlling Access to Powerful Web Platform Features, incluidos el control del usuario, la transparencia y la ergonomía. La capacidad de usar esta API está restringida principalmente por un modelo de permisos que otorga acceso a un solo dispositivo serie a la vez. En respuesta a una solicitud del usuario, este debe realizar pasos activos para seleccionar un dispositivo serie en particular.

Para comprender las compensaciones de seguridad, consulta las secciones de seguridad y privacidad en la explicación de la API de Web Serial.

Comentarios

Al equipo de Chrome le encantaría conocer tus opiniones y experiencias con la API de Web Serial.

Cuéntanos sobre el diseño de la API

¿Hay algo en la API que no funciona como se espera? ¿O faltan métodos o propiedades que necesites para implementar tu idea?

Informa un problema de especificación en el repositorio de GitHub de la API de Web Serial o agrega tus comentarios a un problema existente.

Denuncia un problema con la implementación

¿Encontraste un error en la implementación de Chrome? ¿O la implementación es diferente de la especificación?

Informa un error en https://new.crbug.com. Asegúrate de incluir la mayor cantidad de detalles posible, proporciona instrucciones simples para reproducir el error y establece Componentes en Blink>Serial. Glitch es excelente para compartir reproducciones rápidas y fáciles.

Demostrar apoyo

¿Piensas usar la API de Web Serial? Tu apoyo público ayuda al equipo de Chrome a priorizar las funciones y les muestra a otros proveedores de navegadores lo importante que es admitirlas.

Envía un tuit a @ChromiumDev con el hashtag #SerialAPI y cuéntanos dónde y cómo lo usas.

Vínculos útiles

Demostraciones

Agradecimientos

Agradecemos a Reilly Grant y Joe Medley por revisar este artículo. Foto de la fábrica de aviones de Birmingham Museums Trust en Unsplash.