Lezen van en schrijven naar een seriële poort

Met de Web Serial API kunnen websites communiceren met seriële apparaten.

François Beaufort
François Beaufort

Wat is de Web Serial API?

Een seriële poort is een bidirectionele communicatie-interface waarmee gegevens byte voor byte kunnen worden verzonden en ontvangen.

De Web Serial API biedt websites de mogelijkheid om met JavaScript te lezen van en te schrijven naar een serieel apparaat. Seriële apparaten worden aangesloten via een seriële poort op het systeem van de gebruiker of via verwijderbare USB- en Bluetooth-apparaten die een seriële poort emuleren.

Met andere woorden: de Web Serial API overbrugt het web en de fysieke wereld door websites te laten communiceren met seriële apparaten, zoals microcontrollers en 3D-printers.

Deze API is ook een geweldige aanvulling op WebUSB , omdat besturingssystemen vereisen dat applicaties met sommige seriële poorten communiceren met behulp van hun seriële API op een hoger niveau in plaats van de USB-API op laag niveau.

Voorgestelde gebruiksscenario's

In de onderwijs-, hobby- en industriële sectoren sluiten gebruikers randapparatuur aan op hun computers. Deze apparaten worden vaak bestuurd door microcontrollers via een seriële verbinding die wordt gebruikt door aangepaste software. Sommige aangepaste software om deze apparaten te besturen is gebouwd met webtechnologie:

In sommige gevallen communiceren websites met het apparaat via een agenttoepassing die gebruikers handmatig hebben geïnstalleerd. In andere gevallen wordt de applicatie geleverd in een pakketapplicatie via een raamwerk zoals Electron. En in andere gevallen moet de gebruiker een extra stap uitvoeren, zoals het kopiëren van een gecompileerde applicatie naar het apparaat via een USB-flashstation.

In al deze gevallen wordt de gebruikerservaring verbeterd door directe communicatie te bieden tussen de website en het apparaat dat deze bestuurt.

Huidige status

Stap Toestand
1. Maak een uitleg Compleet
2. Maak een eerste ontwerp van specificatie Compleet
3. Verzamel feedback en herhaal het ontwerp Compleet
4. Oorsprongsproces Compleet
5. Lancering Compleet

Met behulp van de Web Serial API

Functiedetectie

Om te controleren of de Web Serial API wordt ondersteund, gebruikt u:

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

Open een seriële poort

De Web Serial API is asynchroon van opzet. Dit voorkomt dat de gebruikersinterface van de website blokkeert tijdens het wachten op invoer, wat belangrijk is omdat seriële gegevens op elk moment kunnen worden ontvangen, waardoor er een manier nodig is om ernaar te luisteren.

Om een ​​seriële poort te openen, moet u eerst een SerialPort object openen. Hiervoor kunt u de gebruiker vragen een enkele seriële poort te selecteren door navigator.serial.requestPort() aan te roepen als reactie op een gebruikersgebaar zoals aanraken of klikken met de muis, of u kunt er een kiezen uit navigator.serial.getPorts() die retourneert een lijst met seriële poorten waartoe de website toegang heeft gekregen.

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

De functie navigator.serial.requestPort() gebruikt een optioneel letterlijk object dat filters definieert. Deze worden gebruikt om elk serieel apparaat dat via USB is aangesloten, te matchen met een verplichte USB-leverancier ( usbVendorId ) en optionele USB-product-ID's ( 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();
Schermafbeelding van een prompt voor een seriële poort op een website
Gebruikersprompt voor het selecteren van een BBC micro:bit

Door requestPort() aan te roepen, wordt de gebruiker gevraagd een apparaat te selecteren en wordt een SerialPort object geretourneerd. Zodra u een SerialPort object hebt, wordt door het aanroepen van port.open() met de gewenste baudsnelheid de seriële poort geopend. Het baudRate -woordenboeklid specificeert hoe snel gegevens over een seriële lijn worden verzonden. Het wordt uitgedrukt in eenheden van bits per seconde (bps). Controleer de documentatie van uw apparaat voor de juiste waarde, want alle gegevens die u verzendt en ontvangt, zijn onzin als dit verkeerd wordt opgegeven. Voor sommige USB- en Bluetooth-apparaten die een seriële poort emuleren, kan deze waarde veilig op elke waarde worden ingesteld, aangezien deze door de emulatie wordt genegeerd.

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

U kunt ook een van de onderstaande opties opgeven bij het openen van een seriële poort. Deze opties zijn optioneel en hebben handige standaardwaarden .

  • dataBits : Het aantal databits per frame (7 of 8).
  • stopBits : het aantal stopbits aan het einde van een frame (1 of 2).
  • parity : De pariteitsmodus (ofwel "none" , "even" of "odd" ).
  • bufferSize : De grootte van de lees- en schrijfbuffers die moeten worden gemaakt (moet kleiner zijn dan 16 MB).
  • flowControl : De flow control-modus (ofwel "none" of "hardware" ).

Lezen vanaf een seriële poort

Invoer- en uitvoerstromen in de Web Serial API worden afgehandeld door de Streams API.

Nadat de seriële poortverbinding tot stand is gebracht, retourneren de readable en writable eigenschappen van het SerialPort object een ReadableStream en een WritableStream . Deze worden gebruikt om gegevens te ontvangen van en gegevens te verzenden naar het seriële apparaat. Beide gebruiken Uint8Array instanties voor gegevensoverdracht.

Wanneer er nieuwe gegevens binnenkomen vanaf het seriële apparaat, retourneert port.readable.getReader().read() asynchroon twee eigenschappen: de value en een done boolean. Als done waar is, is de seriële poort gesloten of komen er geen gegevens meer binnen. Door port.readable.getReader() aan te roepen, wordt een lezer gemaakt en wordt deze readable . Hoewel readable is vergrendeld , kan de seriële poort niet worden gesloten.

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

Sommige niet-fatale leesfouten van de seriële poort kunnen onder bepaalde omstandigheden optreden, zoals bufferoverloop, framingfouten of pariteitsfouten. Deze worden als uitzonderingen gegenereerd en kunnen worden opgevangen door nog een lus toe te voegen bovenop de vorige die port.readable controleert. Dit werkt omdat, zolang de fouten niet fataal zijn, er automatisch een nieuwe ReadableStream wordt gemaakt. Als er een fatale fout optreedt, bijvoorbeeld als het seriële apparaat wordt verwijderd, wordt port.readable nul.

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

Als het seriële apparaat tekst terugstuurt, kunt u port.readable door een TextDecoderStream leiden, zoals hieronder weergegeven. Een TextDecoderStream is een transformatiestroom die alle Uint8Array chunks oppakt en omzet naar 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);
}

U kunt zelf bepalen hoe geheugen wordt toegewezen wanneer u uit de stream leest met behulp van een "Bring Your Own Buffer"-lezer. Roep port.readable.getReader({ mode: "byob" }) aan om de ReadableStreamBYOBReader- interface op te halen en uw eigen ArrayBuffer op te geven bij het aanroepen van read() . Houd er rekening mee dat de Web Serial API deze functie ondersteunt in Chrome 106 of hoger.

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

Hier is een voorbeeld van hoe u de buffer uit value.buffer opnieuw kunt gebruiken:

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

Hier is nog een voorbeeld van hoe u een specifieke hoeveelheid gegevens van een seriële poort kunt lezen:

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

Schrijf naar een seriële poort

Om gegevens naar een serieel apparaat te verzenden, geeft u gegevens door aan port.writable.getWriter().write() . Het aanroepen releaseLock() op port.writable.getWriter() is vereist om de seriële poort later te sluiten.

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

Stuur tekst naar het apparaat via een TextEncoderStream die naar port.writable wordt geleid, zoals hieronder weergegeven.

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

Sluit een seriële poort

port.close() sluit de seriële poort als de readable en writable leden ervan zijn ontgrendeld , wat betekent dat releaseLock() is aangeroepen voor hun respectievelijke lezer en schrijver.

await port.close();

Wanneer u echter voortdurend gegevens leest van een serieel apparaat met behulp van een lus, wordt port.readable altijd vergrendeld totdat er een fout optreedt. In dit geval zorgt het aanroepen van reader.cancel() ervoor dat reader.read() onmiddellijk wordt omgezet met { value: undefined, done: true } , waardoor de lus reader.releaseLock() kan aanroepen.

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

Het sluiten van een seriële poort is ingewikkelder bij gebruik van transformatiestromen . Roep reader.cancel() aan zoals voorheen. Roep vervolgens writer.close() en port.close() aan. Dit verspreidt fouten via de transformatiestromen naar de onderliggende seriële poort. Omdat de foutpropagatie niet onmiddellijk plaatsvindt, moet u de readableStreamClosed en writableStreamClosed beloften gebruiken die eerder zijn gemaakt om te detecteren wanneer port.readable en port.writable zijn ontgrendeld. Als u de reader annuleert, wordt de stream afgebroken; dit is de reden waarom u de resulterende fout moet opvangen en negeren.

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

Luister naar verbinding en ontkoppeling

Als een USB-apparaat over een seriële poort beschikt, kan dat apparaat op het systeem worden aangesloten of losgekoppeld. Wanneer aan de website toestemming is verleend om toegang te krijgen tot een seriële poort, moet deze de connect en disconnect monitoren.

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

Omgaan met signalen

Nadat u de seriële poortverbinding tot stand hebt gebracht, kunt u signalen die door de seriële poort worden weergegeven expliciet opvragen en instellen voor apparaatdetectie en stroomcontrole. Deze signalen worden gedefinieerd als Booleaanse waarden. Sommige apparaten, zoals Arduino, gaan bijvoorbeeld naar een programmeermodus als het Data Terminal Ready (DTR)-signaal wordt omgeschakeld.

Het instellen van uitvoersignalen en het verkrijgen van invoersignalen wordt respectievelijk gedaan door port.setSignals() en port.getSignals() aan te roepen. Zie gebruiksvoorbeelden hieronder.

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

Stromen transformeren

Wanneer u gegevens ontvangt van het seriële apparaat, ontvangt u niet noodzakelijkerwijs alle gegevens in één keer. Het kan willekeurig in stukken worden gesneden. Zie Streams API-concepten voor meer informatie.

Om hiermee om te gaan, kunt u enkele ingebouwde transformatiestromen gebruiken, zoals TextDecoderStream , of uw eigen transformatiestroom maken waarmee u de binnenkomende stroom kunt parseren en geparseerde gegevens kunt retourneren. De transformatiestroom bevindt zich tussen het seriële apparaat en de leeslus die de stroom verbruikt. Het kan een willekeurige transformatie toepassen voordat de gegevens worden verbruikt. Zie het als een lopende band: als een widget langs de lijn komt, wordt de widget bij elke stap in de lijn aangepast, zodat deze tegen de tijd dat hij zijn eindbestemming bereikt, een volledig functionerende widget is.

Foto van een vliegtuigfabriek
Vliegtuigfabriek Castle Bromwich uit de Tweede Wereldoorlog

Overweeg bijvoorbeeld hoe u een transformatiestreamklasse kunt maken die een stream verbruikt en deze opsplitst op basis van regeleinden. De transform() methode wordt aangeroepen telkens wanneer nieuwe gegevens door de stream worden ontvangen. Het kan de gegevens in de wachtrij plaatsen of voor later opslaan. De methode flush() wordt aangeroepen wanneer de stream wordt gesloten en verwerkt alle gegevens die nog niet zijn verwerkt.

Als u de klasse transform stream wilt gebruiken, moet u er een inkomende stream doorheen leiden. In het derde codevoorbeeld onder Lezen vanaf een seriële poort werd de oorspronkelijke invoerstroom alleen door een TextDecoderStream geleid, dus moeten we pipeThrough() aanroepen om deze door onze nieuwe LineBreakTransformer te leiden.

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

Voor het opsporen van communicatieproblemen met seriële apparaten gebruikt u de tee() methode van port.readable om de stromen die van of naar het seriële apparaat gaan te splitsen. De twee gecreëerde streams kunnen onafhankelijk van elkaar worden gebruikt, zodat u er één ter inspectie naar de console kunt afdrukken.

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.

Toegang tot een seriële poort intrekken

De website kan de machtigingen voor toegang tot een seriële poort opschonen die hij niet langer wil behouden door forget() aan te roepen op de SerialPort instantie. Voor een educatieve webapplicatie die op een gedeelde computer met veel apparaten wordt gebruikt, zorgt een groot aantal verzamelde, door gebruikers gegenereerde machtigingen bijvoorbeeld voor een slechte gebruikerservaring.

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

Omdat forget() beschikbaar is in Chrome 103 of hoger, controleer je of deze functie wordt ondersteund met het volgende:

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

Ontwikkelaarstips

Het debuggen van de Web Serial API in Chrome is eenvoudig met de interne pagina, about://device-log waar u alle seriële apparaatgerelateerde gebeurtenissen op één plek kunt zien.

Schermafbeelding van de interne pagina voor het opsporen van fouten in de Web Serial API.
Interne pagina in Chrome voor het opsporen van fouten in de Web Serial API.

Codelab

In het Google Developer-codelab gebruik je de Web Serial API om te communiceren met een BBC micro:bit- bord om afbeeldingen weer te geven op de 5x5 LED-matrix.

Browser-ondersteuning

De Web Serial API is beschikbaar op alle desktopplatforms (ChromeOS, Linux, macOS en Windows) in Chrome 89.

Polyvulling

Op Android is ondersteuning voor USB-gebaseerde seriële poorten mogelijk met behulp van de WebUSB API en de Serial API polyfill . Deze polyfill is beperkt tot hardware en platforms waarop het apparaat toegankelijk is via de WebUSB API, omdat deze niet wordt geclaimd door een ingebouwd apparaatstuurprogramma.

Veiligheid en privacy

De auteurs van de specificaties hebben de Web Serial API ontworpen en geïmplementeerd met behulp van de kernprincipes die zijn gedefinieerd in Controlling Access to Powerful Web Platform Features , inclusief gebruikerscontrole, transparantie en ergonomie. De mogelijkheid om deze API te gebruiken wordt voornamelijk bepaald door een toestemmingsmodel dat toegang verleent tot slechts één serieel apparaat tegelijk. Als reactie op een gebruikersprompt moet de gebruiker actieve stappen ondernemen om een ​​bepaald serieel apparaat te selecteren.

Om de afwegingen op het gebied van beveiliging te begrijpen, kunt u de secties over beveiliging en privacy van de Web Serial API Exploreer raadplegen.

Feedback

Het Chrome-team hoort graag uw mening en ervaringen met de Web Serial API.

Vertel ons over het API-ontwerp

Is er iets aan de API dat niet werkt zoals verwacht? Of ontbreken er methoden of eigenschappen die je nodig hebt om je idee te implementeren?

Dien een spec-probleem in op de Web Serial API GitHub-opslagplaats of voeg uw mening toe aan een bestaand probleem.

Meld een probleem met de implementatie

Heeft u een bug gevonden in de implementatie van Chrome? Of wijkt de uitvoering af van de specificaties?

Dien een bug in op https://new.crbug.com . Zorg ervoor dat u zoveel mogelijk details vermeldt, geef eenvoudige instructies voor het reproduceren van de bug en zorg ervoor dat Componenten zijn ingesteld op Blink>Serial . Glitch werkt uitstekend voor het delen van snelle en gemakkelijke reproducties.

Toon steun

Bent u van plan de Web Serial API te gebruiken? Uw publieke steun helpt het Chrome-team prioriteiten te stellen voor functies en laat andere browserleveranciers zien hoe belangrijk het is om deze te ondersteunen.

Stuur een tweet naar @ChromiumDev met de hashtag #SerialAPI en laat ons weten waar en hoe u deze gebruikt.

Handige Links

Demo's

Dankbetuigingen

Met dank aan Reilly Grant en Joe Medley voor hun recensies van dit artikel. Vliegtuigfabriekfoto door Birmingham Museums Trust op Unsplash .