Lire et écrire sur un port série

L'API Web Serial permet aux sites Web de communiquer avec des appareils série.

François Beaufort
François Beaufort

Qu'est-ce que l'API Web Serial ?

Un port série est une interface de communication bidirectionnelle qui permet d'envoyer et de recevoir des données octet par octet.

L'API Web Serial permet aux sites Web de lire et d'écrire sur un appareil sériel avec JavaScript. Les appareils série sont connectés via un port série sur le système de l'utilisateur ou via des appareils USB et Bluetooth amovibles qui émulent un port série.

En d'autres termes, l'API Web Serial fait le lien entre le Web et le monde physique en permettant aux sites Web de communiquer avec des appareils série, tels que des microcontrôleurs et des imprimantes 3D.

Cette API est également un excellent complément à WebUSB, car les systèmes d'exploitation exigent que les applications communiquent avec certains ports série à l'aide de leur API série de niveau supérieur plutôt que de l'API USB de bas niveau.

Cas d'utilisation suggérés

Dans les secteurs de l'éducation, des loisirs et de l'industrie, les utilisateurs connectent des périphériques à leurs ordinateurs. Ces appareils sont souvent contrôlés par des microcontrôleurs via une connexion série utilisée par un logiciel personnalisé. Certains logiciels personnalisés permettant de contrôler ces appareils sont conçus avec des technologies Web :

Dans certains cas, les sites Web communiquent avec l'appareil via une application d'agent que les utilisateurs ont installée manuellement. Dans d'autres, l'application est fournie dans une application packagée via un framework tel qu'Electron. Dans d'autres, l'utilisateur doit effectuer une étape supplémentaire, comme copier une application compilée sur l'appareil à l'aide d'une clé USB.

Dans tous ces cas, l'expérience utilisateur sera améliorée grâce à une communication directe entre le site Web et l'appareil qu'il contrôle.

État actuel

Étape État
1. Créer une explication Fin
2. Créer une première ébauche de spécification Fin
3. Recueillir des commentaires et améliorer la conception Fin
4. Essai Origin Trial Fin
5. Lancer Fin

Utiliser l'API Web Serial

Détection de caractéristiques

Pour vérifier si l'API Web Serial est compatible, utilisez le code suivant :

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

Ouvrir un port série

L'API Web Serial est asynchrone par conception. Cela empêche l'UI du site Web de se bloquer lors de l'attente d'une entrée, ce qui est important, car les données série peuvent être reçues à tout moment, ce qui nécessite un moyen de les écouter.

Pour ouvrir un port série, accédez d'abord à un objet SerialPort. Pour ce faire, vous pouvez inviter l'utilisateur à sélectionner un seul port série en appelant navigator.serial.requestPort() en réponse à un geste de l'utilisateur, tel qu'un appui ou un clic de souris, ou en choisir un dans navigator.serial.getPorts(), qui renvoie une liste des ports série auxquels le site Web a été autorisé à accéder.

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 fonction navigator.serial.requestPort() utilise un littéral d'objet facultatif qui définit les filtres. Ils sont utilisés pour faire correspondre tout appareil série connecté via USB avec un fournisseur USB obligatoire (usbVendorId) et des identifiants de produit USB facultatifs (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();
Capture d'écran d'une invite de port série sur un site Web
Invite utilisateur pour sélectionner un BBC micro:bit

L'appel à requestPort() invite l'utilisateur à sélectionner un appareil et renvoie un objet SerialPort. Une fois que vous disposez d'un objet SerialPort, l'appel de port.open() avec le débit en bauds souhaité ouvre le port série. Le membre du dictionnaire baudRate spécifie la vitesse à laquelle les données sont envoyées sur une ligne série. Elle est exprimée en bits par seconde (bps). Consultez la documentation de votre appareil pour connaître la valeur correcte. Si elle est incorrecte, toutes les données que vous envoyez et recevez seront incompréhensibles. Pour certains appareils USB et Bluetooth qui émulent un port série, cette valeur peut être définie sur n'importe quelle valeur, car elle est ignorée par l'émulation.

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

Vous pouvez également spécifier l'une des options ci-dessous lorsque vous ouvrez un port série. Ces options sont facultatives et disposent de valeurs par défaut pratiques.

  • dataBits : nombre de bits de données par trame (7 ou 8).
  • stopBits : nombre de bits d'arrêt à la fin d'une trame (1 ou 2).
  • parity : mode de parité ("none", "even" ou "odd").
  • bufferSize : taille des tampons de lecture et d'écriture à créer (doit être inférieure à 16 Mo).
  • flowControl : mode de contrôle du flux ("none" ou "hardware").

Lire les données d'un port série

Les flux d'entrée et de sortie de l'API Web Serial sont gérés par l'API Streams.

Une fois la connexion au port série établie, les propriétés readable et writable de l'objet SerialPort renvoient un ReadableStream et un WritableStream. Ils seront utilisés pour recevoir des données de l'appareil série et lui en envoyer. Les deux utilisent des instances Uint8Array pour le transfert de données.

Lorsque de nouvelles données arrivent de l'appareil série, port.readable.getReader().read() renvoie deux propriétés de manière asynchrone : value et un booléen done. Si done est défini sur "true", cela signifie que le port série a été fermé ou qu'aucune autre donnée n'est reçue. L'appel de port.readable.getReader() crée un lecteur et verrouille readable sur celui-ci. Lorsque readable est verrouillé, le port série ne peut pas être fermé.

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

Dans certaines conditions, des erreurs de lecture non fatales du port série peuvent se produire, par exemple en cas de dépassement de capacité du tampon, d'erreurs de cadrage ou d'erreurs de parité. Elles sont générées sous forme d'exceptions et peuvent être interceptées en ajoutant une autre boucle au-dessus de la précédente, qui vérifie port.readable. Cela fonctionne, car tant que les erreurs ne sont pas fatales, un nouveau ReadableStream est créé automatiquement. Si une erreur fatale se produit, par exemple si le périphérique série est supprimé, port.readable devient 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.
  }
}

Si le périphérique série renvoie du texte, vous pouvez rediriger port.readable via un TextDecoderStream, comme indiqué ci-dessous. Un TextDecoderStream est un flux de transformation qui récupère tous les blocs Uint8Array et les convertit en chaînes.

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

Vous pouvez contrôler la façon dont la mémoire est allouée lorsque vous lisez le flux à l'aide d'un lecteur "Bring Your Own Buffer". Appelez port.readable.getReader({ mode: "byob" }) pour obtenir l'interface ReadableStreamBYOBReader et fournissez votre propre ArrayBuffer lorsque vous appelez read(). Notez que l'API Web Serial est compatible avec cette fonctionnalité dans Chrome 106 ou version ultérieure.

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

Voici un exemple de réutilisation du tampon hors 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`.
}

Voici un autre exemple de lecture d'une quantité spécifique de données à partir d'un port série :

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

Écrire sur un port série

Pour envoyer des données à un appareil série, transmettez-les à port.writable.getWriter().write(). L'appel de releaseLock() sur port.writable.getWriter() est nécessaire pour que le port série puisse être fermé ultérieurement.

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

Envoyez du texte à l'appareil via un TextEncoderStream redirigé vers port.writable, comme indiqué ci-dessous.

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

Fermer un port série

port.close() ferme le port série si ses membres readable et writable sont déverrouillés, ce qui signifie que releaseLock() a été appelé pour leur lecteur et leur rédacteur respectifs.

await port.close();

Toutefois, lors de la lecture continue de données à partir d'un périphérique série à l'aide d'une boucle, port.readable sera toujours verrouillé jusqu'à ce qu'il rencontre une erreur. Dans ce cas, l'appel de reader.cancel() forcera reader.read() à se résoudre immédiatement avec { value: undefined, done: true }, ce qui permettra à la boucle d'appeler 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 fermeture d'un port série est plus complexe lorsque vous utilisez des flux de transformation. Appelez reader.cancel() comme avant. Appelez ensuite writer.close() et port.close(). Cela propage les erreurs via les flux de transformation vers le port série sous-jacent. Étant donné que la propagation des erreurs ne se produit pas immédiatement, vous devez utiliser les promesses readableStreamClosed et writableStreamClosed créées précédemment pour détecter quand port.readable et port.writable ont été déverrouillées. L'annulation de reader entraîne l'abandon du flux. C'est pourquoi vous devez intercepter et ignorer l'erreur qui en résulte.

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

Écouter les événements de connexion et de déconnexion

Si un port série est fourni par un appareil USB, cet appareil peut être connecté ou déconnecté du système. Lorsque le site Web est autorisé à accéder à un port série, il doit surveiller les événements connect et 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.
});

Gérer les signaux

Une fois la connexion au port série établie, vous pouvez interroger et définir explicitement les signaux exposés par le port série pour la détection des appareils et le contrôle du flux. Ces signaux sont définis comme des valeurs booléennes. Par exemple, certains appareils tels qu'Arduino passent en mode programmation si le signal DTR (Data Terminal Ready) est activé/désactivé.

La définition des signaux de sortie et l'obtention des signaux d'entrée se font respectivement en appelant port.setSignals() et port.getSignals(). Consultez les exemples d'utilisation ci-dessous.

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

Transformer les flux

Lorsque vous recevez des données de l'appareil série, vous ne les obtenez pas nécessairement toutes en même temps. Il peut être segmenté de manière arbitraire. Pour en savoir plus, consultez Concepts de l'API Streams.

Pour résoudre ce problème, vous pouvez utiliser des flux de transformation intégrés tels que TextDecoderStream ou créer votre propre flux de transformation, ce qui vous permet d'analyser le flux entrant et de renvoyer les données analysées. Le flux de transformation se situe entre le périphérique série et la boucle de lecture qui consomme le flux. Il peut appliquer une transformation arbitraire avant que les données ne soient utilisées. Imaginez une chaîne de montage : à mesure qu'un widget avance sur la chaîne, chaque étape le modifie. Ainsi, lorsqu'il arrive à sa destination finale, il est entièrement fonctionnel.

Photo d&#39;une usine d&#39;avions
Usine d'avions de Castle Bromwich pendant la Seconde Guerre mondiale

Par exemple, réfléchissez à la manière de créer une classe de flux de transformation qui consomme un flux et le segmente en fonction des sauts de ligne. Sa méthode transform() est appelée chaque fois que de nouvelles données sont reçues par le flux. Il peut mettre les données en file d'attente ou les enregistrer pour plus tard. La méthode flush() est appelée lorsque le flux est fermé et gère toutes les données qui n'ont pas encore été traitées.

Pour utiliser la classe de flux de transformation, vous devez y rediriger un flux entrant. Dans le troisième exemple de code sous Lire à partir d'un port série, le flux d'entrée d'origine n'a été transmis que par un TextDecoderStream. Nous devons donc appeler pipeThrough() pour le transmettre par notre nouveau 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();

Pour déboguer les problèmes de communication des appareils série, utilisez la méthode tee() de port.readable pour diviser les flux allant vers ou provenant de l'appareil série. Les deux flux créés peuvent être utilisés indépendamment, ce qui vous permet d'en imprimer un dans la console pour l'inspecter.

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.

Révoquer l'accès à un port série

Le site Web peut supprimer les autorisations d'accès à un port série qu'il ne souhaite plus conserver en appelant forget() sur l'instance SerialPort. Par exemple, pour une application Web éducative utilisée sur un ordinateur partagé avec de nombreux appareils, un grand nombre d'autorisations générées par les utilisateurs crée une mauvaise expérience utilisateur.

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

Comme forget() est disponible dans Chrome 103 ou version ultérieure, vérifiez si cette fonctionnalité est prise en charge en procédant comme suit :

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

Conseils pour les développeurs

Le débogage de l'API Web Serial dans Chrome est facile grâce à la page interne about://device-log, qui vous permet de voir tous les événements liés aux appareils série au même endroit.

Capture d&#39;écran de la page interne permettant de déboguer l&#39;API Web Serial.
Page interne de Chrome permettant de déboguer l'API Web Serial.

Atelier de programmation

Dans l'atelier de programmation Google Developers, vous allez utiliser l'API Web Serial pour interagir avec une carte BBC micro:bit afin d'afficher des images sur sa matrice LED 5x5.

Prise en charge des navigateurs

L'API Web Serial est disponible sur toutes les plates-formes de bureau (ChromeOS, Linux, macOS et Windows) dans Chrome 89.

Remplissage

Sur Android, la prise en charge des ports série basés sur USB est possible à l'aide de l'API WebUSB et du polyfill de l'API Serial. Ce polyfill est limité au matériel et aux plates-formes où l'appareil est accessible via l'API WebUSB, car il n'a pas été revendiqué par un pilote d'appareil intégré.

Sécurité et confidentialité

Les auteurs de la spécification ont conçu et implémenté l'API Web Serial en utilisant les principes de base définis dans Contrôler l'accès aux fonctionnalités puissantes de la plate-forme Web, y compris le contrôle par l'utilisateur, la transparence et l'ergonomie. La possibilité d'utiliser cette API est principalement régie par un modèle d'autorisation qui n'accorde l'accès qu'à un seul périphérique série à la fois. En réponse à une requête utilisateur, l'utilisateur doit effectuer des étapes actives pour sélectionner un appareil série spécifique.

Pour comprendre les compromis en matière de sécurité, consultez les sections Sécurité et Confidentialité de l'explication de l'API Web Serial.

Commentaires

L'équipe Chrome aimerait connaître votre avis et votre expérience concernant l'API Web Serial.

Parlez-nous de la conception de l'API

Y a-t-il un élément de l'API qui ne fonctionne pas comme prévu ? Ou bien manquent-ils des méthodes ou des propriétés dont vous avez besoin pour mettre en œuvre votre idée ?

Signalez un problème de spécification dans le dépôt GitHub de l'API Web Serial ou ajoutez vos commentaires à un problème existant.

Signaler un problème d'implémentation

Avez-vous trouvé un bug dans l'implémentation de Chrome ? Ou l'implémentation est-elle différente de la spécification ?

Signalez un bug sur https://new.crbug.com. Veillez à inclure autant de détails que possible, à fournir des instructions simples pour reproduire le bug et à définir Composants sur Blink>Serial.

Montrer votre soutien

Comptez-vous utiliser l'API Web Serial ? Votre soutien public aide l'équipe Chrome à hiérarchiser les fonctionnalités et montre aux autres fournisseurs de navigateurs à quel point il est essentiel de les prendre en charge.

Envoyez un tweet à @ChromiumDev avec le hashtag #SerialAPI pour nous indiquer où et comment vous l'utilisez.

Liens utiles

Démonstrations

Remerciements

Merci à Reilly Grant et Joe Medley pour leurs commentaires sur cet article. Photo d'une usine d'avions par Birmingham Museums Trust sur Unsplash.