Lettura e scrittura su una porta seriale

L'API Web Serial consente ai siti web di comunicare con i dispositivi seriali.

François Beaufort
François Beaufort

Che cos'è l'API Web Serial?

Una porta seriale è un'interfaccia di comunicazione bidirezionale che consente di inviare e ricevere dati byte per byte.

L'API Web Serial consente ai siti web di leggere e scrivere su un dispositivo seriale con JavaScript. I dispositivi seriali sono collegati tramite una porta seriale sul sistema dell'utente o tramite dispositivi USB e Bluetooth rimovibili che emulano una porta seriale.

In altre parole, l'API Web Serial collega il web a quello fisico, consentendo ai siti web di comunicare con dispositivi seriali, come microcontroller e stampanti 3D.

Questa API è anche un'ottima compagna di WebUSB, in quanto i sistemi operativi richiedono alle applicazioni di comunicare con alcune porte seriali utilizzando l'API seriale di livello superiore anziché l'API USB di basso livello.

Casi d'uso suggeriti

Nel settore educativo, hobby e industriale, gli utenti collegano dispositivi periferici ai propri computer. Questi dispositivi sono spesso controllati da microcontroller tramite una connessione seriale usata da software personalizzati. Un software personalizzato per controllare questi dispositivi è realizzato con la tecnologia web:

In alcuni casi, i siti web comunicano con il dispositivo tramite un'applicazione agente installata manualmente dagli utenti. In altri casi, l'applicazione viene fornita in un'applicazione in pacchetto tramite un framework come Electron. In altri casi, l'utente deve eseguire un passaggio aggiuntivo, come copiare un'applicazione compilata sul dispositivo tramite un'unità flash USB.

In tutti questi casi, l'esperienza utente sarà migliorata grazie a una comunicazione diretta tra il sito web e il dispositivo che controlla.

Stato attuale

Passaggio Stato
1. Crea messaggio esplicativo Completato
2. Crea una bozza iniziale della specifica Completato
3. Raccogli feedback e ottimizza il design Completato
4. Prova dell'origine Completato
5. Avvia Completato

Utilizzo dell'API Web Serial

Rilevamento delle funzionalità

Per verificare se l'API Web Serial è supportata, utilizza:

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

Apri una porta seriale

L'API Web Serial è asincrona per progettazione. In questo modo, l'interfaccia utente del sito web non si blocca quando è in attesa di input, il che è importante perché i dati seriali possono essere ricevuti in qualsiasi momento, perciò è necessario un modo per ascoltarli.

Per aprire una porta seriale, devi prima accedere a un oggetto SerialPort. Per farlo, puoi chiedere all'utente di selezionare una singola porta seriale chiamando navigator.serial.requestPort() in risposta a un gesto dell'utente, come il tocco o il clic del mouse, oppure sceglierne una da navigator.serial.getPorts() che restituisce un elenco delle porte seriali a cui è stato concesso l'accesso al sito 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 funzione navigator.serial.requestPort() accetta un valore letterale oggetto facoltativo che definisce i filtri. Questi vengono utilizzati per abbinare qualsiasi dispositivo seriale connesso tramite USB a un fornitore USB obbligatorio (usbVendorId) e agli ID prodotto USB facoltativi (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();
Screenshot della richiesta di una porta seriale su un sito web
Prompt dell'utente per la selezione di un micro:bit BBC

La chiamata a requestPort() richiede all'utente di selezionare un dispositivo e restituisce un oggetto SerialPort. Quando hai un oggetto SerialPort, se chiami port.open() con la velocità in baud desiderata, si aprirà la porta seriale. Il membro del dizionario baudRate specifica la velocità di invio dei dati su una linea seriale. È espresso in unità di bit al secondo (bps). Controlla se nella documentazione del dispositivo è presente il valore corretto, poiché tutti i dati inviati e ricevuti saranno senza senso se specificato in modo errato. Per alcuni dispositivi USB e Bluetooth che emulano una porta seriale, questo valore può essere impostato in modo sicuro su qualsiasi valore, in quanto viene ignorato dall'emulazione.

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

Quando apri una porta seriale, puoi anche specificare una delle opzioni seguenti. Queste opzioni sono facoltative e hanno valori predefiniti utili.

  • dataBits: il numero di bit di dati per frame (7 o 8).
  • stopBits: il numero di bit di stop alla fine di un frame (1 o 2).
  • parity: la modalità di parità ("none", "even" o "odd").
  • bufferSize: la dimensione dei buffer di lettura e scrittura che devono essere creati (deve essere inferiore a 16 MB).
  • flowControl: la modalità di controllo del flusso ("none" o "hardware").

Lettura da una porta seriale

I flussi di input e di output nell'API Web Serial vengono gestiti dall'API Streams.

Una volta stabilita la connessione alla porta seriale, le proprietà readable e writable dell'oggetto SerialPort restituiscono un elemento ReadableStream e WritableStream. Questi verranno utilizzati per ricevere e inviare dati al dispositivo di serie. Entrambi utilizzano istanze Uint8Array per il trasferimento di dati.

Quando arrivano nuovi dati dal dispositivo seriale, port.readable.getReader().read() restituisce due proprietà in modo asincrono: value e un valore booleano done. Se done è true, la porta seriale è stata chiusa o non ci sono altri dati in arrivo. La chiamata a port.readable.getReader() crea un lettore e blocca readable a questo lettore. Se readable è bloccato, non è possibile chiudere la porta seriale.

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

Alcuni errori di lettura non irreversibili sulla porta seriale possono verificarsi in determinate condizioni, ad esempio overflow del buffer, errori di inquadratura o errori di parità. Queste vengono generate come eccezioni e possono essere rilevate aggiungendo un altro loop sopra quello precedente che controlla port.readable. Questo metodo funziona perché, a condizione che gli errori non siano irreversibili, viene creato automaticamente un nuovo ReadableStream. In caso di errore irreversibile, ad esempio la rimozione del dispositivo seriale, port.readable diventa nullo.

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 il dispositivo seriale restituisce un messaggio, puoi reindirizzare port.readable a un TextDecoderStream, come mostrato di seguito. Un TextDecoderStream è un stream trasformato che recupera tutti i blocchi Uint8Array e li converte in stringhe.

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

Puoi controllare la modalità di allocazione della memoria quando leggi dallo stream utilizzando un lettore "Bring Your Own Buffer". Richiama port.readable.getReader({ mode: "byob" }) per ottenere l'interfaccia ReadableStreamBYOBReader e fornire il tuo ArrayBuffer durante la chiamata a read(). Tieni presente che l'API Web Serial supporta questa funzionalità in Chrome 106 o versioni successive.

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

Ecco un esempio di come riutilizzare il buffer da 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`.
}

Ecco un altro esempio di come leggere una quantità specifica di dati da una porta seriale:

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

Scrittura su una porta seriale

Per inviare dati a un dispositivo seriale, trasmettili a port.writable.getWriter().write(). È necessario chiamare releaseLock() su port.writable.getWriter() per chiudere in un secondo momento la porta seriale.

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

Invia testo al dispositivo tramite un TextEncoderStream inviato a port.writable, come mostrato di seguito.

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

Chiudere una porta seriale

port.close() chiude la porta seriale se i suoi membri readable e writable sono sbloccati, il che significa che releaseLock() è stato chiamato per il rispettivo lettore e scrittore.

await port.close();

Tuttavia, durante la lettura continua dei dati da un dispositivo seriale utilizzando un loop, port.readable sarà sempre bloccato finché non riscontra un errore. In questo caso, la chiamata a reader.cancel() forzerà la risoluzione immediata di reader.read() con { value: undefined, done: true }, consentendo al loop di chiamare 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;
});

La chiusura di una porta seriale è più complicata quando utilizzi i trasformazioni dei flussi. Chiama reader.cancel() come prima. Quindi chiama writer.close() e port.close(). In questo modo gli errori vengono propagati tramite i flussi di trasformazione alla porta seriale sottostante. Poiché la propagazione degli errori non avviene immediatamente, devi utilizzare le promesse readableStreamClosed e writableStreamClosed create in precedenza per rilevare quando port.readable e port.writable sono stati sbloccati. L'annullamento di reader determina l'interruzione del flusso; ecco perché devi individuare e ignorare l'errore risultante.

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

Ascoltare la connessione e la disconnessione

Se un dispositivo USB fornisce una porta seriale, quest'ultimo potrebbe essere collegato o disconnesso dal sistema. Una volta concessa l'autorizzazione ad accedere a una porta seriale, il sito web deve monitorare gli eventi 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.
});

Gestire gli indicatori

Dopo aver stabilito la connessione alla porta seriale, puoi eseguire query e impostare in modo esplicito gli indicatori esposti dalla porta seriale per il rilevamento dei dispositivi e il controllo del flusso. Questi indicatori sono definiti come valori booleani. Ad esempio, alcuni dispositivi come Arduino entreranno in una modalità di programmazione se il segnale DTR (Data Terminal Ready) viene attivato.

L'impostazione degli indicatori di output e il recupero degli indicatori di input vengono rispettivamente eseguiti chiamando port.setSignals() e port.getSignals(). Consulta gli esempi di utilizzo riportati di seguito.

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

Trasformazione dei flussi

Quando ricevi dati dal dispositivo seriale, non riceverai necessariamente tutti i dati in una sola volta. Può essere suddiviso in modo arbitrario. Per ulteriori informazioni, consulta i concetti dell'API Streams.

Per risolvere questo problema, puoi utilizzare alcuni flussi di trasformazione integrati come TextDecoderStream o creare il tuo flusso di trasformazione che ti consente di analizzare il flusso in entrata e restituire i dati analizzati. Il flusso di trasformazione si trova tra il dispositivo seriale e il loop di lettura che utilizza il flusso. Può applicare una trasformazione arbitraria prima che i dati vengano consumati. Immaginalo come una catena di montaggio: mentre un widget scende lungo la linea, ogni passaggio nella riga modifica il widget in modo che, quando arriva alla sua destinazione finale, sia un widget completamente funzionante.

Foto di una fabbrica di aeroplani
Fabbrica di aerei Castle Bromwich della seconda guerra mondiale

Ad esempio, valuta come creare una classe di flusso di trasformazione che utilizza un flusso e lo suddivide in blocchi in base alle interruzioni di riga. Il suo metodo transform() viene chiamato ogni volta che il flusso riceve nuovi dati. Può mettere in coda i dati o salvarli per un secondo momento. Il metodo flush() viene chiamato quando il flusso è chiuso e gestisce tutti i dati non ancora elaborati.

Per utilizzare la classe di flusso Transform, devi utilizzare la pipe per un flusso in entrata. Nel terzo esempio di codice in Lettura da una porta seriale, il flusso di input originale era indirizzato solo tramite un TextDecoderStream, quindi dobbiamo chiamare pipeThrough() per trasmetterlo al nuovo 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();

Per eseguire il debug dei problemi di comunicazione dei dispositivi seriali, utilizza il metodo tee() di port.readable per suddividere gli stream da o verso il dispositivo seriale. I due flussi creati possono essere utilizzati in modo indipendente e in questo modo puoi stamparne uno sulla console per l'ispezione.

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.

Revocare l'accesso a una porta seriale

Il sito web può rimuovere le autorizzazioni per accedere a una porta seriale che non è più interessata a conservare chiamando forget() sull'istanza SerialPort. Ad esempio, per un'applicazione web didattica utilizzata su un computer condiviso con molti dispositivi, un numero elevato di autorizzazioni accumulate dall'utente crea un'esperienza utente negativa.

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

Poiché forget() è disponibile in Chrome 103 o versioni successive, controlla se questa funzionalità è supportata con:

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

Suggerimenti per sviluppatori

Il debug dell'API Web Serial in Chrome è semplice grazie alla pagina interna, about://device-log, in cui puoi visualizzare tutti gli eventi relativi al dispositivo seriale in un'unica posizione.

Screenshot della pagina interna per il debug dell&#39;API Web Serial.
Pagina interna in Chrome per il debug dell'API Web Serial.

Codelab

Nel codelab di Google Developer, utilizzerai l'API Web Serial per interagire con una scheda BBC micro:bit per mostrare immagini sulla sua matrice LED 5x5.

Supporto del browser

L'API Web Serial è disponibile su tutte le piattaforme desktop (ChromeOS, Linux, macOS e Windows) in Chrome 89.

Polyfill

Su Android, il supporto delle porte seriali basate su USB è possibile utilizzando l'API WebUSB e il polyfill dell'API Serial. Questo polyfill è limitato ad hardware e piattaforme in cui il dispositivo è accessibile tramite l'API WebUSB perché non è stato rivendicato da un driver di dispositivo integrato.

Sicurezza e privacy

Gli autori delle specifiche hanno progettato e implementato l'API Web Serial utilizzando i principi fondamentali definiti nell'articolo Controllare l'accesso alle funzionalità avanzate della piattaforma web, tra cui controllo dell'utente, trasparenza ed ergonomia. La possibilità di utilizzare questa API è controllata principalmente da un modello di autorizzazione che concede l'accesso a un solo dispositivo di serie alla volta. In risposta a una richiesta dell'utente, l'utente deve eseguire dei passaggi attivi per selezionare un determinato dispositivo seriale.

Per comprendere i compromessi in termini di sicurezza, consulta le sezioni sulla sicurezza e sulla privacy dell'API Web Serial API Explainer.

Feedback

Il team di Chrome vorrebbe conoscere la tua opinione ed esperienze in merito all'API Web Serial.

Parlaci della progettazione dell'API

C'è qualcosa nell'API che non funziona come previsto? Oppure mancano metodi o proprietà di cui hai bisogno per implementare la tua idea?

Segnala un problema relativo alle specifiche sul repository GitHub dell'API Web Serial o aggiungi le tue idee a un problema esistente.

Segnala un problema con l'implementazione

Hai trovato un bug nell'implementazione di Chrome? Oppure l'implementazione è diversa dalle specifiche?

Segnala un bug all'indirizzo https://new.crbug.com. Assicurati di includere il maggior numero di dettagli possibile, di fornire semplici istruzioni per riprodurre il bug e di avere impostato i componenti su Blink>Serial. Glitch funziona benissimo per condividere riproduzioni rapide e semplici.

Mostra assistenza

Intendi utilizzare l'API Web Serial? Il supporto pubblico aiuta il team di Chrome a dare la priorità alle funzionalità e mostra ad altri fornitori di browser quanto sia fondamentale supportarle.

Invia un tweet a @ChromiumDev usando l'hashtag #SerialAPI e facci sapere dove e come lo stai usando.

Link utili

Demo

Ringraziamenti

Ringraziamo Reilly Grant e Joe Medley per le loro recensioni su questo articolo. Foto della fabbrica di aerei di Birmingham Museums Trust su Unsplash.