Aus einem seriellen Port lesen und darauf schreiben

Mit der Web Serial API können Websites mit seriellen Geräten kommunizieren.

François Beaufort
François Beaufort

Was ist die Web Serial API?

Ein serieller Port ist eine bidirektionale Kommunikationsschnittstelle, über die Daten byte für byte gesendet und empfangen werden können.

Die Web Serial API bietet Websites die Möglichkeit, mit JavaScript Daten von einem seriellen Gerät zu lesen und auf ein serielles Gerät zu schreiben. Serielle Geräte werden entweder über einen seriellen Port auf dem System des Nutzers oder über Wechsel-USB- und Bluetooth-Geräte verbunden, die einen seriellen Port emulieren.

Mit der Web Serial API wird also eine Brücke zwischen dem Web und der physischen Welt geschlagen, da Websites mit seriellen Geräten wie Mikrocontrollern und 3D-Druckern kommunizieren können.

Diese API ist auch eine gute Ergänzung zu WebUSB, da Betriebssysteme erfordern, dass Anwendungen mit einigen seriellen Ports über ihre Serial API auf höherer Ebene und nicht über die USB API auf niedriger Ebene kommunizieren.

Empfohlene Anwendungsfälle

Im Bildungs-, Hobby- und Industriesektor verbinden Nutzer Peripheriegeräte mit ihren Computern. Diese Geräte werden häufig von Mikrocontrollern über eine serielle Verbindung gesteuert, die von benutzerdefinierter Software verwendet wird. Einige benutzerdefinierte Software zur Steuerung dieser Geräte basiert auf Webtechnologie:

In einigen Fällen kommunizieren Websites über eine Agent-Anwendung mit dem Gerät, die Nutzer manuell installiert haben. In anderen Fällen wird die Anwendung in einer verpackten Anwendung über ein Framework wie Electron bereitgestellt. In anderen Fällen muss der Nutzer einen zusätzlichen Schritt ausführen, z. B. eine kompilierte Anwendung über einen USB-Stick auf das Gerät kopieren.

In all diesen Fällen wird die Nutzerfreundlichkeit durch die direkte Kommunikation zwischen der Website und dem Gerät, das sie steuert, verbessert.

Aktueller Status

Schritt Status
1. Erklärung erstellen Abschließen
2. Ersten Entwurf der Spezifikation erstellen Abschließen
3. Feedback einholen und Design iterieren Abschließen
4. Ursprungstest Abschließen
5. Starten Abschließen

Web Serial API verwenden

Funktionserkennung

So prüfen Sie, ob die Web Serial API unterstützt wird:

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

Seriellen Port öffnen

Die Web Serial API ist von Grund auf asynchron. Dadurch wird verhindert, dass die Benutzeroberfläche der Website blockiert wird, wenn auf eine Eingabe gewartet wird. Das ist wichtig, da serielle Daten jederzeit empfangen werden können und daher eine Möglichkeit zum Abhören erforderlich ist.

Um eine serielle Schnittstelle zu öffnen, müssen Sie zuerst auf ein SerialPort-Objekt zugreifen. Dazu können Sie den Nutzer entweder auffordern, einen einzelnen seriellen Port auszuwählen, indem Sie navigator.serial.requestPort() als Reaktion auf eine Nutzeraktion wie eine Berührung oder einen Mausklick aufrufen, oder einen aus navigator.serial.getPorts() auswählen, wodurch eine Liste der seriellen Ports zurückgegeben wird, auf die die Website Zugriff hat.

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

Die Funktion navigator.serial.requestPort() verwendet ein optionales Objektliteral, das Filter definiert. Sie werden verwendet, um ein serielles Gerät, das über USB verbunden ist, mit einem obligatorischen USB-Anbieter (usbVendorId) und optionalen USB-Produktkennungen (usbProductId) abzugleichen.

// 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 einer Eingabeaufforderung für einen seriellen Port auf einer Website
Nutzer-Prompt für die Auswahl eines BBC micro:bit

Beim Aufrufen von requestPort() wird der Nutzer aufgefordert, ein Gerät auszuwählen, und es wird ein SerialPort-Objekt zurückgegeben. Sobald Sie ein SerialPort-Objekt haben, wird durch Aufrufen von port.open() mit der gewünschten Baudrate die serielle Schnittstelle geöffnet. Das baudRate-Wörterbuchelement gibt an, wie schnell Daten über eine serielle Leitung gesendet werden. Sie wird in Bit pro Sekunde (bps) angegeben. Sehen Sie in der Dokumentation Ihres Geräts nach, welcher Wert richtig ist. Wenn er falsch angegeben ist, sind alle Daten, die Sie senden und empfangen, unleserlich. Bei einigen USB- und Bluetooth-Geräten, die einen seriellen Port emulieren, kann dieser Wert gefahrlos auf einen beliebigen Wert gesetzt werden, da er von der Emulation ignoriert wird.

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

Sie können auch eine der folgenden Optionen angeben, wenn Sie einen seriellen Port öffnen. Diese Optionen sind optional und haben praktische Standardwerte.

  • dataBits: Die Anzahl der Datenbits pro Frame (entweder 7 oder 8).
  • stopBits: Die Anzahl der Stoppbits am Ende eines Frames (entweder 1 oder 2).
  • parity: Der Paritätsmodus (entweder "none", "even" oder "odd").
  • bufferSize: Die Größe der Lese- und Schreibpuffer, die erstellt werden sollen (muss kleiner als 16 MB sein).
  • flowControl: Der Flusssteuerungsmodus (entweder "none" oder "hardware").

Von einem seriellen Port lesen

Ein- und Ausgabestreams in der Web Serial API werden von der Streams API verarbeitet.

Nachdem die Verbindung über den seriellen Port hergestellt wurde, geben die Attribute readable und writable des SerialPort-Objekts einen ReadableStream und einen WritableStream zurück. Sie werden verwendet, um Daten vom seriellen Gerät zu empfangen und Daten an das serielle Gerät zu senden. Beide verwenden Uint8Array-Instanzen für die Datenübertragung.

Wenn neue Daten vom seriellen Gerät eingehen, gibt port.readable.getReader().read() asynchron zwei Attribute zurück: value und einen booleschen Wert done. Wenn done „true“ ist, wurde der serielle Port geschlossen oder es werden keine Daten mehr empfangen. Durch Aufrufen von port.readable.getReader() wird ein Reader erstellt und readable wird für diesen gesperrt. Wenn readable gesperrt ist, kann der serielle Port nicht geschlossen werden.

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

Unter bestimmten Umständen können einige nicht schwerwiegende Lesefehler am seriellen Port auftreten, z. B. Pufferüberlauf, Framing-Fehler oder Paritätsfehler. Diese werden als Ausnahmen ausgegeben und können abgefangen werden, indem Sie eine weitere Schleife über der vorherigen hinzufügen, die port.readable prüft. Das funktioniert, weil bei nicht schwerwiegenden Fehlern automatisch ein neuer ReadableStream erstellt wird. Wenn ein schwerwiegender Fehler auftritt, z. B. wenn das serielle Gerät entfernt wird, wird port.readable zu „null“.

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

Wenn das serielle Gerät Text zurücksendet, können Sie port.readable wie unten gezeigt durch ein TextDecoderStream leiten. Ein TextDecoderStream ist ein Transformationsstream, der alle Uint8Array-Chunks abruft und in Strings konvertiert.

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

Mit einem „Bring Your Own Buffer“-Reader können Sie selbst festlegen, wie Speicher zugewiesen wird, wenn Sie Daten aus dem Stream lesen. Rufen Sie port.readable.getReader({ mode: "byob" }) auf, um die ReadableStreamBYOBReader-Schnittstelle zu erhalten, und stellen Sie beim Aufrufen von read() Ihr eigenes ArrayBuffer bereit. Hinweis: Die Web Serial API unterstützt diese Funktion in Chrome 106 oder höher.

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 ist ein Beispiel dafür, wie Sie den Puffer aus value.buffer wiederverwenden können:

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 ein weiteres Beispiel dafür, wie eine bestimmte Datenmenge von einem seriellen Port gelesen wird:

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

In einen seriellen Port schreiben

Wenn Sie Daten an ein serielles Gerät senden möchten, übergeben Sie die Daten an port.writable.getWriter().write(). Der Aufruf von releaseLock() auf port.writable.getWriter() ist erforderlich, damit der serielle Port später geschlossen werden kann.

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

Senden Sie Text über ein TextEncoderStream, das an port.writable weitergeleitet wird, an das Gerät, wie unten gezeigt.

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

Seriellen Port schließen

port.close() schließt den seriellen Port, wenn seine readable- und writable-Member entsperrt sind. Das bedeutet, dass releaseLock() für den jeweiligen Reader und Writer aufgerufen wurde.

await port.close();

Wenn Sie jedoch Daten von einem seriellen Gerät kontinuierlich über eine Schleife lesen, wird port.readable immer gesperrt, bis ein Fehler auftritt. In diesem Fall wird durch den Aufruf von reader.cancel() die sofortige Auflösung von reader.read() mit { value: undefined, done: true } erzwungen, sodass die Schleife reader.releaseLock() aufrufen kann.

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

Das Schließen eines seriellen Ports ist komplizierter, wenn Transform-Streams verwendet werden. Rufen Sie reader.cancel() wie gewohnt an. Rufen Sie dann writer.close() und port.close() auf. Dadurch werden Fehler über die Transformationsstreams an den zugrunde liegenden seriellen Port weitergegeben. Da die Fehlerweitergabe nicht sofort erfolgt, müssen Sie die zuvor erstellten Promises readableStreamClosed und writableStreamClosed verwenden, um zu erkennen, wann port.readable und port.writable entsperrt wurden. Wenn Sie reader abbrechen, wird der Stream beendet. Daher müssen Sie den resultierenden Fehler abfangen und ignorieren.

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

Verbindungen und Trennungen anhören

Wenn ein serieller Port von einem USB-Gerät bereitgestellt wird, kann dieses Gerät mit dem System verbunden oder davon getrennt werden. Wenn der Website die Berechtigung zum Zugriff auf einen seriellen Port erteilt wurde, sollte sie die Ereignisse connect und disconnect überwachen.

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

Signale verarbeiten

Nachdem Sie die Verbindung zum seriellen Port hergestellt haben, können Sie explizit Signale abfragen und festlegen, die vom seriellen Port für die Geräteerkennung und Flusssteuerung bereitgestellt werden. Diese Signale sind als boolesche Werte definiert. Einige Geräte wie Arduino wechseln beispielsweise in den Programmiermodus, wenn das DTR-Signal (Data Terminal Ready) umgeschaltet wird.

Ausgabesignale werden durch Aufrufen von port.setSignals() festgelegt und Eingabesignale durch Aufrufen von port.getSignals() abgerufen. Unten finden Sie Anwendungsbeispiele.

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

Streams transformieren

Wenn Sie Daten vom seriellen Gerät empfangen, erhalten Sie nicht unbedingt alle Daten auf einmal. Sie kann beliebig in Abschnitte unterteilt werden. Weitere Informationen finden Sie unter Streams API – Konzepte.

Um dieses Problem zu beheben, können Sie einige integrierte Transformationsstreams wie TextDecoderStream verwenden oder einen eigenen Transformationsstream erstellen, mit dem Sie den eingehenden Stream parsen und geparste Daten zurückgeben können. Der Transformationsstream befindet sich zwischen dem seriellen Gerät und der Leseschleife, die den Stream verarbeitet. Es kann eine beliebige Transformation anwenden, bevor die Daten verwendet werden. Stellen Sie sich das wie ein Fließband vor: Wenn ein Widget das Band durchläuft, wird es in jedem Schritt modifiziert, sodass es am Ende ein voll funktionsfähiges Widget ist.

Foto einer Flugzeugfabrik
World War II Castle Bromwich Aeroplane Factory

Überlegen Sie sich beispielsweise, wie Sie eine Transform-Stream-Klasse erstellen, die einen Stream verarbeitet und ihn anhand von Zeilenumbrüchen in Chunks aufteilt. Die transform()-Methode wird jedes Mal aufgerufen, wenn neue Daten vom Stream empfangen werden. Die Daten können entweder in die Warteschlange gestellt oder für später gespeichert werden. Die Methode flush() wird aufgerufen, wenn der Stream geschlossen wird. Sie verarbeitet alle Daten, die noch nicht verarbeitet wurden.

Wenn Sie die Transform-Stream-Klasse verwenden möchten, müssen Sie einen eingehenden Stream durch sie leiten. Im dritten Codebeispiel unter Von einem seriellen Port lesen wurde der ursprüngliche Eingabestream nur durch ein TextDecoderStream geleitet. Daher müssen wir pipeThrough() aufrufen, um ihn durch unser neues LineBreakTransformer zu leiten.

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

Verwenden Sie zum Beheben von Problemen bei der Kommunikation mit seriellen Geräten die Methode tee() von port.readable, um die Streams zu oder von dem seriellen Gerät aufzuteilen. Die beiden erstellten Streams können unabhängig voneinander genutzt werden. So können Sie einen zur Überprüfung in der Konsole ausgeben.

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.

Zugriff auf einen seriellen Port widerrufen

Die Website kann Berechtigungen für den Zugriff auf einen seriellen Port entfernen, der nicht mehr benötigt wird, indem sie forget() für die SerialPort-Instanz aufruft. Wenn beispielsweise eine Bildungs-Webanwendung auf einem gemeinsam genutzten Computer mit vielen Geräten verwendet wird, führt eine große Anzahl von angesammelten nutzergenerierten Berechtigungen zu einer schlechten Nutzererfahrung.

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

Da forget() in Chrome 103 oder höher verfügbar ist, prüfen Sie, ob diese Funktion mit den folgenden Elementen unterstützt wird:

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

Tipps für Entwickler

Das Debuggen der Web Serial API in Chrome ist mit der internen Seite about://device-log ganz einfach. Dort können Sie alle Ereignisse im Zusammenhang mit seriellen Geräten an einem Ort sehen.

Screenshot der internen Seite zum Debuggen der Web Serial API.
Interne Seite in Chrome zum Debuggen der Web Serial API.

Codelab

In diesem Google Developer-Codelab verwenden Sie die Web Serial API, um mit einem BBC micro:bit-Board zu interagieren und Bilder auf der 5 × 5-LED-Matrix anzuzeigen.

Unterstützte Browser

Die Web Serial API ist in Chrome 89 auf allen Desktop-Plattformen (ChromeOS, Linux, macOS und Windows) verfügbar.

Polyfill

Unter Android ist die Unterstützung von USB-basierten seriellen Ports über die WebUSB API und das Serial API-Polyfill möglich. Dieses Polyfill ist auf Hardware und Plattformen beschränkt, auf denen das Gerät über die WebUSB API zugänglich ist, da es nicht von einem integrierten Gerätetreiber beansprucht wurde.

Sicherheit und Datenschutz

Die Autoren der Spezifikation haben die Web Serial API unter Berücksichtigung der in Controlling Access to Powerful Web Platform Features (Zugriff auf leistungsstarke Webplattformfunktionen steuern) definierten Grundsätze entwickelt und implementiert, darunter Nutzerkontrolle, Transparenz und Ergonomie. Die Verwendung dieser API wird in erster Linie durch ein Berechtigungsmodell eingeschränkt, das jeweils nur Zugriff auf ein einzelnes serielles Gerät gewährt. Als Reaktion auf eine Nutzeraufforderung muss der Nutzer aktiv ein bestimmtes serielles Gerät auswählen.

Informationen zu den Sicherheitsrisiken finden Sie in den Abschnitten Sicherheit und Datenschutz des Web Serial API Explainer.

Feedback

Das Chrome-Team würde sich freuen, mehr über Ihre Gedanken und Erfahrungen mit der Web Serial API zu erfahren.

Informationen zum API-Design

Gibt es etwas an der API, das nicht wie erwartet funktioniert? Oder fehlen Methoden oder Properties, die Sie für die Umsetzung Ihrer Idee benötigen?

Melden Sie ein Spezifikationsproblem im GitHub-Repository der Web Serial API oder fügen Sie Ihre Gedanken zu einem bestehenden Problem hinzu.

Problem mit der Implementierung melden

Haben Sie einen Fehler in der Chrome-Implementierung gefunden? Oder weicht die Implementierung von der Spezifikation ab?

Melden Sie einen Fehler unter https://new.crbug.com. Geben Sie so viele Details wie möglich an, stellen Sie eine einfache Anleitung zum Reproduzieren des Fehlers bereit und legen Sie Components auf Blink>Serial fest.

Unterstützung zeigen

Planen Sie, die Web Serial API zu verwenden? Ihre öffentliche Unterstützung hilft dem Chrome-Team, Funktionen zu priorisieren, und zeigt anderen Browseranbietern, wie wichtig es ist, sie zu unterstützen.

Senden Sie einen Tweet an @ChromiumDev mit dem Hashtag #SerialAPI und teilen Sie uns mit, wo und wie Sie die Funktion verwenden.

Nützliche Links

Demos

Danksagungen

Vielen Dank an Reilly Grant und Joe Medley für die Überprüfung dieses Artikels. Foto einer Flugzeugfabrik von Birmingham Museums Trust auf Unsplash.