Portage d'applications USB sur le Web Partie 1: libusb

Découvrez comment le code qui interagit avec des appareils externes peut être porté sur le Web avec les API WebAssembly et Fugu.

Dans un post précédent, j'ai expliqué comment porter des applications utilisant des API de système de fichiers sur le Web avec l'API File System Access, WebAssembly et Asyncify. Je vais maintenant poursuivre sur le même sujet, à savoir l'intégration des API Fugu avec WebAssembly et le portage d'applications sur le Web sans perdre de fonctionnalités importantes.

Je vais vous montrer comment les applications qui communiquent avec des appareils USB peuvent être portées sur le Web en transférant libusb, une bibliothèque USB populaire écrite en C, vers WebAssembly (via Emscripten), Asyncify et WebUSB.

Tout d'abord, une démonstration

Le plus important à faire lors du portage d'une bibliothèque est de choisir la bonne démonstration. Elle doit mettre en avant les fonctionnalités de la bibliothèque portée, vous permettre de la tester de différentes manières et être visuellement attrayante.

L'idée que j'ai choisie était une télécommande pour appareil photo reflex numérique. En particulier, le projet Open Source gPhoto2 existe depuis suffisamment longtemps pour avoir implémenté la compatibilité avec une grande variété d'appareils photo numériques. Il est compatible avec plusieurs protocoles, mais celui qui m'intéressait le plus était la compatibilité USB, qu'il assure via libusb.

Je vais décrire la création de cette démonstration en deux parties. Dans cet article de blog, je vais décrire comment j'ai porté libusb lui-même et les astuces qui peuvent être nécessaires pour porter d'autres bibliothèques populaires vers les API Fugu. Dans le deuxième article, je vais détailler le portage et l'intégration de gPhoto2 lui-même.

J'ai finalement obtenu une application Web fonctionnelle qui prévisualise le flux en direct d'un appareil photo reflex numérique et qui peut contrôler ses paramètres via USB. N'hésitez pas à regarder la démonstration en direct ou la version enregistrée avant de consulter les détails techniques:

Démonstration exécutée sur un ordinateur portable connecté à un appareil photo Sony.

Remarque concernant les particularités propres à la caméra

Vous avez peut-être remarqué que le changement de paramètres prend un certain temps dans la vidéo. Comme pour la plupart des autres problèmes que vous pourriez rencontrer, cela n'est pas dû aux performances de WebAssembly ou de WebUSB, mais à la façon dont gPhoto2 interagit avec l'appareil photo spécifique choisi pour la démonstration.

Le Sony a6600 n'expose pas d'API pour définir directement des valeurs telles que l'ISO, l'ouverture ou la vitesse d'obturation. Il ne fournit que des commandes pour les augmenter ou les diminuer du nombre d'étapes spécifié. Pour compliquer la situation, il ne renvoie pas non plus la liste des valeurs réellement acceptées. La liste renvoyée semble codée en dur sur de nombreux modèles d'appareils photo Sony.

Lorsque vous définissez l'une de ces valeurs, gPhoto2 n'a pas d'autre choix que de:

  1. Effectuez une ou plusieurs étapes dans la direction de la valeur choisie.
  2. Patientez un moment, le temps que la caméra mette à jour les paramètres.
  3. Lire la valeur sur laquelle la caméra s'est arrêtée.
  4. Vérifiez que la dernière étape n'a pas ignoré la valeur souhaitée ni contourné la fin ou le début de la liste.
  5. Recommencez.

Cela peut prendre un certain temps, mais si la valeur est réellement compatible avec l'appareil photo, elle y arrivera. Dans le cas contraire, elle s'arrêtera à la valeur compatible la plus proche.

Les autres caméras auront probablement des ensembles de paramètres, des API sous-jacentes et des particularités différents. N'oubliez pas que gPhoto2 est un projet Open Source, et que les tests automatisés ou manuels de tous les modèles d'appareils photo existants ne sont tout simplement pas réalisables. Par conséquent, les rapports détaillés sur les problèmes et les PR sont toujours les bienvenus (mais assurez-vous d'abord de reproduire les problèmes avec le client gPhoto2 officiel).

Remarques importantes sur la compatibilité multiplate-forme

Malheureusement, sous Windows, tous les appareils "connus", y compris les appareils photo reflex numériques, sont associés à un pilote système, qui n'est pas compatible avec WebUSB. Si vous souhaitez essayer la démonstration sous Windows, vous devez utiliser un outil tel que Zadig pour remplacer le pilote de l'appareil photo reflex numérique connecté par WinUSB ou libusb. Cette approche fonctionne bien pour moi et de nombreux autres utilisateurs, mais vous devez l'utiliser à vos propres risques.

Sous Linux, vous devrez probablement définir des autorisations personnalisées pour autoriser l'accès à votre appareil photo reflex numérique via WebUSB, mais cela dépend de votre distribution.

Sous macOS et Android, la démonstration devrait fonctionner immédiatement. Si vous l'essayez sur un téléphone Android, veillez à passer en mode paysage, car je n'ai pas beaucoup travaillé sur la réactivité (les PR sont les bienvenus):

Téléphone Android connecté à un appareil photo Canon à l'aide d'un câble USB-C.
Même démonstration exécutée sur un téléphone Android. Photo de Surma.

Pour un guide plus détaillé sur l'utilisation multiplate-forme de WebUSB, consultez la section "Considérations spécifiques à la plate-forme" de "Créer un appareil pour WebUSB".

Ajout d'un nouveau backend à libusb

Passons maintenant aux détails techniques. Bien qu'il soit possible de fournir une API de shim semblable à libusb (d'autres l'ont déjà fait) et d'y associer d'autres applications, cette approche est sujette aux erreurs et rend toute extension ou maintenance ultérieure plus difficile. Je voulais faire les choses correctement, de manière à pouvoir les contribuer en amont et à les fusionner dans libusb à l'avenir.

Heureusement, le fichier README de libusb indique:

"libusb est abstrait en interne de manière à pouvoir être porté sur d'autres systèmes d'exploitation. Pour en savoir plus, consultez le fichier PORTING."

libusb est structuré de sorte que l'API publique soit distincte des "backends". Ces backends sont chargés de lister, d'ouvrir, de fermer et de communiquer avec les appareils via les API de bas niveau du système d'exploitation. C'est ainsi que libusb élimine déjà les différences entre Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku et Solaris, et fonctionne sur toutes ces plates-formes.

J'ai dû ajouter un autre backend pour le "système d'exploitation" Emscripten+WebUSB. Les implémentations de ces backends se trouvent dans le dossier libusb/os:

~/w/d/libusb $ ls libusb/os
darwin_usb.c           haiku_usb_raw.h  threads_posix.lo
darwin_usb.h           linux_netlink.c  threads_posix.o
events_posix.c         linux_udev.c     threads_windows.c
events_posix.h         linux_usbfs.c    threads_windows.h
events_posix.lo        linux_usbfs.h    windows_common.c
events_posix.o         netbsd_usb.c     windows_common.h
events_windows.c       null_usb.c       windows_usbdk.c
events_windows.h       openbsd_usb.c    windows_usbdk.h
haiku_pollfs.cpp       sunos_usb.c      windows_winusb.c
haiku_usb_backend.cpp  sunos_usb.h      windows_winusb.h
haiku_usb.h            threads_posix.c
haiku_usb_raw.cpp      threads_posix.h

Chaque backend inclut l'en-tête libusbi.h avec des types et des outils d'assistance courants, et doit exposer une variable usbi_backend de type usbi_os_backend. Par exemple, voici à quoi ressemble le backend Windows:

const struct usbi_os_backend usbi_backend = {
  "Windows",
  USBI_CAP_HAS_HID_ACCESS,
  windows_init,
  windows_exit,
  windows_set_option,
  windows_get_device_list,
  NULL,   /* hotplug_poll */
  NULL,   /* wrap_sys_device */
  windows_open,
  windows_close,
  windows_get_active_config_descriptor,
  windows_get_config_descriptor,
  windows_get_config_descriptor_by_value,
  windows_get_configuration,
  windows_set_configuration,
  windows_claim_interface,
  windows_release_interface,
  windows_set_interface_altsetting,
  windows_clear_halt,
  windows_reset_device,
  NULL,   /* alloc_streams */
  NULL,   /* free_streams */
  NULL,   /* dev_mem_alloc */
  NULL,   /* dev_mem_free */
  NULL,   /* kernel_driver_active */
  NULL,   /* detach_kernel_driver */
  NULL,   /* attach_kernel_driver */
  windows_destroy_device,
  windows_submit_transfer,
  windows_cancel_transfer,
  NULL,   /* clear_transfer_priv */
  NULL,   /* handle_events */
  windows_handle_transfer_completion,
  sizeof(struct windows_context_priv),
  sizeof(union windows_device_priv),
  sizeof(struct windows_device_handle_priv),
  sizeof(struct windows_transfer_priv),
};

En examinant les propriétés, nous pouvons voir que la structure inclut le nom du backend, un ensemble de ses fonctionnalités, des gestionnaires pour diverses opérations USB de bas niveau sous la forme de pointeurs de fonction et, enfin, des tailles à allouer pour stocker des données privées au niveau de l'appareil/du contexte/du transfert.

Les champs de données privées sont utiles au moins pour stocker les poignées d'OS pour tous ces éléments, car sans poignées, nous ne savons pas à quel élément une opération donnée s'applique. Dans l'implémentation Web, les poignées de l'OS correspondraient aux objets JavaScript WebUSB sous-jacents. Le moyen naturel de les représenter et de les stocker dans Emscripten est via la classe emscripten::val, fournie dans Embind (système de liaisons d'Emscripten).

La plupart des backends du dossier sont implémentés en C, mais certains le sont en C++. Embind ne fonctionne qu'avec C++, le choix m'a donc été imposé. J'ai ajouté libusb/libusb/os/emscripten_webusb.cpp avec la structure requise et sizeof(val) pour les champs de données privées:

#include <emscripten.h>
#include <emscripten/val.h>

#include "libusbi.h"

using namespace emscripten;

// …function implementations

const usbi_os_backend usbi_backend = {
  .name = "Emscripten + WebUSB backend",
  .caps = LIBUSB_CAP_HAS_CAPABILITY,
  // …handlers—function pointers to implementations above
  .device_priv_size = sizeof(val),
  .transfer_priv_size = sizeof(val),
};

Stocker des objets WebUSB en tant que poignées d'appareil

libusb fournit des pointeurs prêts à l'emploi vers la zone allouée pour les données privées. Pour utiliser ces pointeurs en tant qu'instances val, j'ai ajouté de petits outils d'assistance qui les construisent sur place, les récupèrent en tant que références et déplacent les valeurs:

// We store an Embind handle to WebUSB USBDevice in "priv" metadata of
// libusb device, this helper returns a pointer to it.
struct ValPtr {
 public:
  void init_to(val &&value) { new (ptr) val(std::move(value)); }

  val &get() { return *ptr; }
  val take() { return std::move(get()); }

 protected:
  ValPtr(val *ptr) : ptr(ptr) {}

 private:
  val *ptr;
};

struct WebUsbDevicePtr : ValPtr {
 public:
  WebUsbDevicePtr(libusb_device *dev)
      : ValPtr(static_cast<val *>(usbi_get_device_priv(dev))) {}
};

val &get_web_usb_device(libusb_device *dev) {
  return WebUsbDevicePtr(dev).get();
}

struct WebUsbTransferPtr : ValPtr {
 public:
  WebUsbTransferPtr(usbi_transfer *itransfer)
      : ValPtr(static_cast<val *>(usbi_get_transfer_priv(itransfer))) {}
};

API Web asynchrones dans des contextes C synchrones

Il est maintenant nécessaire de trouver un moyen de gérer les API WebUSB asynchrones, car libusb s'attend à des opérations synchrones. Pour ce faire, je pourrais utiliser Asyncify, ou plus précisément son intégration Embind via val::await().

Je voulais également gérer correctement les erreurs WebUSB et les convertir en codes d'erreur libusb, mais Embind n'a actuellement aucun moyen de gérer les exceptions JavaScript ni les refus Promise côté C++. Pour contourner ce problème, vous pouvez détecter un refus côté JavaScript et convertir le résultat en objet { error, value }, qui peut désormais être analysé en toute sécurité côté C++. J'ai fait cela en combinant la macro EM_JS et les API Emval.to{Handle, Value}:

EM_JS(EM_VAL, em_promise_catch_impl, (EM_VAL handle), {
  let promise = Emval.toValue(handle);
  promise = promise.then(
    value => ({error : 0, value}),
    error => {
      const ERROR_CODES = {
        // LIBUSB_ERROR_IO
        NetworkError : -1,
        // LIBUSB_ERROR_INVALID_PARAM
        DataError : -2,
        TypeMismatchError : -2,
        IndexSizeError : -2,
        // LIBUSB_ERROR_ACCESS
        SecurityError : -3,
        …
      };
      console.error(error);
      let errorCode = -99; // LIBUSB_ERROR_OTHER
      if (error instanceof DOMException)
      {
        errorCode = ERROR_CODES[error.name] ?? errorCode;
      }
      else if (error instanceof RangeError || error instanceof TypeError)
      {
        errorCode = -2; // LIBUSB_ERROR_INVALID_PARAM
      }
      return {error: errorCode, value: undefined};
    }
  );
  return Emval.toHandle(promise);
});

val em_promise_catch(val &&promise) {
  EM_VAL handle = promise.as_handle();
  handle = em_promise_catch_impl(handle);
  return val::take_ownership(handle);
}

// C++ struct representation for {value, error} object from above
// (performs conversion in the constructor).
struct promise_result {
  libusb_error error;
  val value;

  promise_result(val &&result)
      : error(static_cast<libusb_error>(result["error"].as<int>())),
        value(result["value"]) {}

  // C++ counterpart of the promise helper above that takes a promise, catches
  // its error, converts to a libusb status and returns the whole thing as
  // `promise_result` struct for easier handling.
  static promise_result await(val &&promise) {
    promise = em_promise_catch(std::move(promise));
    return {promise.await()};
  }
};

Je peux maintenant utiliser promise_result::await() sur n'importe quel Promise renvoyé par les opérations WebUSB et inspecter ses champs error et value séparément.

Par exemple, voici comment récupérer un val représentant un USBDevice à partir de libusb_device_handle, appeler sa méthode open(), attendre son résultat et renvoyer un code d'erreur en tant que code d'état libusb:

int em_open(libusb_device_handle *handle) {
  auto web_usb_device = get_web_usb_device(handle->dev);
  return promise_result::await(web_usb_device.call<val>("open")).error;
}

Énumération des appareils

Bien entendu, avant de pouvoir ouvrir un appareil, libusb doit récupérer la liste des appareils disponibles. Le backend doit implémenter cette opération via un gestionnaire get_device_list.

La difficulté est que, contrairement aux autres plates-formes, il n'est pas possible d'énumérer tous les appareils USB connectés sur le Web pour des raisons de sécurité. Le flux est divisé en deux parties. Tout d'abord, l'application Web demande des appareils avec des propriétés spécifiques via navigator.usb.requestDevice(). L'utilisateur choisit ensuite manuellement l'appareil qu'il souhaite exposer ou refuse la requête d'autorisation. Ensuite, l'application liste les appareils déjà approuvés et connectés via navigator.usb.getDevices().

Au début, j'ai essayé d'utiliser requestDevice() directement dans l'implémentation du gestionnaire get_device_list. Toutefois, l'affichage d'une invite d'autorisation avec une liste des appareils connectés est considéré comme une opération sensible et doit être déclenché par une interaction de l'utilisateur (comme un clic sur un bouton sur une page). Sinon, il renvoie toujours une promesse refusée. Les applications libusb peuvent souvent vouloir lister les appareils connectés au démarrage de l'application. L'utilisation de requestDevice() n'était donc pas une option.

Au lieu de cela, j'ai dû laisser l'appel de navigator.usb.requestDevice() au développeur final et n'exposer que les appareils déjà approuvés à partir de navigator.usb.getDevices():

// Store the global `navigator.usb` once upon initialisation.
thread_local const val web_usb = val::global("navigator")["usb"];

int em_get_device_list(libusb_context *ctx, discovered_devs **devs) {
  // C++ equivalent of `await navigator.usb.getDevices()`.
  // Note: at this point we must already have some devices exposed -
  // caller must have called `await navigator.usb.requestDevice(...)`
  // in response to user interaction before going to LibUSB.
  // Otherwise this list will be empty.
  auto result = promise_result::await(web_usb.call<val>("getDevices"));
  if (result.error) {
    return result.error;
  }
  auto &web_usb_devices = result.value;
  // Iterate over the exposed devices.
  uint8_t devices_num = web_usb_devices["length"].as<uint8_t>();
  for (uint8_t i = 0; i < devices_num; i++) {
    auto web_usb_device = web_usb_devices[i];
    // …
    *devs = discovered_devs_append(*devs, dev);
  }
  return LIBUSB_SUCCESS;
}

La majeure partie du code backend utilise val et promise_result de la même manière que celle illustrée ci-dessus. Le code de gestion du transfert de données contient quelques autres astuces intéressantes, mais ces détails d'implémentation sont moins importants pour les besoins de cet article. Si vous êtes intéressé, n'hésitez pas à consulter le code et les commentaires sur GitHub.

Portage des boucles d'événements sur le Web

Je souhaite également aborder un autre aspect du port libusb : la gestion des événements. Comme décrit dans l'article précédent, la plupart des API dans les langages système tels que C sont synchrones, et la gestion des événements ne fait pas exception. Il est généralement implémenté via une boucle infinie qui "interroge" (tente de lire des données ou bloque l'exécution jusqu'à ce que certaines données soient disponibles) à partir d'un ensemble de sources d'E/S externes et, lorsqu'au moins l'une d'elles répond, transmet cela en tant qu'événement au gestionnaire correspondant. Une fois le gestionnaire terminé, le contrôle revient à la boucle et se met en pause pour une autre interrogation.

Cette approche pose un certain nombre de problèmes sur le Web.

Tout d'abord, WebUSB n'expose pas et ne peut pas exposer les poignées brutes des appareils sous-jacents. Il n'est donc pas possible de les interroger directement. Deuxièmement, libusb utilise les API eventfd et pipe pour d'autres événements, ainsi que pour gérer les transferts sur des systèmes d'exploitation sans poignées d'appareil brutes. Toutefois, eventfd n'est actuellement pas compatible avec Emscripten, et pipe, bien que compatible, n'est actuellement pas conforme à la spécification et ne peut pas attendre les événements.

Enfin, le plus gros problème est que le Web possède sa propre boucle d'événements. Cette boucle d'événements globale est utilisée pour toutes les opérations d'E/S externes (y compris fetch(), les minuteurs ou, dans ce cas, WebUSB), et elle appelle des gestionnaires d'événements ou de Promise chaque fois que les opérations correspondantes se terminent. L'exécution d'une autre boucle d'événements infinie imbriquée empêche la progression de la boucle d'événements du navigateur. Par conséquent, non seulement l'UI ne répond plus, mais le code ne reçoit jamais de notifications pour les mêmes événements d'E/S qu'il attend. Cela entraîne généralement un blocage, et c'est également ce qui s'est produit lorsque j'ai essayé d'utiliser libusb dans une démonstration. La page s'est figée.

Comme pour les autres E/S bloquantes, pour porter ces boucles d'événements sur le Web, les développeurs doivent trouver un moyen de les exécuter sans bloquer le thread principal. Une façon de procéder consiste à refactoriser l'application pour gérer les événements d'E/S dans un thread distinct et à renvoyer les résultats au thread principal. L'autre consiste à utiliser Asyncify pour suspendre la boucle et attendre les événements de manière non bloquante.

Je ne voulais pas apporter de modifications importantes à libusb ni à gPhoto2, et j'ai déjà utilisé Asyncify pour l'intégration de Promise. C'est donc le chemin que j'ai choisi. Pour simuler une variante bloquante de poll(), j'ai utilisé une boucle pour la preuve de concept initiale, comme indiqué ci-dessous:

#ifdef __EMSCRIPTEN__
  // TODO: optimize this. Right now it will keep unwinding-rewinding the stack
  // on each short sleep until an event comes or the timeout expires.
  // We should probably create an actual separate thread that does signaling
  // or come up with a custom event mechanism to report events from
  // `usbi_signal_event` and process them here.
  double until_time = emscripten_get_now() + timeout_ms;
  do {
    // Emscripten `poll` ignores timeout param, but pass 0 explicitly just
    // in case.
    num_ready = poll(fds, nfds, 0);
    if (num_ready != 0) break;
    // Yield to the browser event loop to handle events.
    emscripten_sleep(0);
  } while (emscripten_get_now() < until_time);
#else
  num_ready = poll(fds, nfds, timeout_ms);
#endif

Voici ce qu'il fait:

  1. Appels poll() pour vérifier si des événements ont déjà été signalés par le backend. Si c'est le cas, la boucle s'arrête. Sinon, l'implémentation de poll() par Emscripten renvoie immédiatement 0.
  2. Il appelle emscripten_sleep(0). Cette fonction utilise Asyncify et setTimeout() en interne et est utilisée ici pour céder le contrôle à la boucle d'événements principale du navigateur. Cela permet au navigateur de gérer toutes les interactions utilisateur et les événements d'E/S, y compris WebUSB.
  3. Vérifiez si le délai spécifié a déjà expiré. Si ce n'est pas le cas, poursuivez la boucle.

Comme indiqué dans le commentaire, cette approche n'était pas optimale, car elle continuait à enregistrer et à restaurer l'ensemble de la pile d'appels avec Asyncify, même lorsqu'il n'y avait pas encore d'événements USB à gérer (la plupart du temps), et parce que setTimeout() elle-même a une durée minimale de 4 ms dans les navigateurs modernes. Cependant, il a fonctionné suffisamment bien pour produire un streaming en direct de 13 à 14 FPS à partir d'un appareil photo reflex numérique dans la preuve de concept.

Plus tard, j'ai décidé de l'améliorer en exploitant le système d'événements du navigateur. Cette implémentation peut être améliorée de plusieurs manières, mais pour l'instant, j'ai choisi d'émettre des événements personnalisés directement sur l'objet global, sans les associer à une structure de données libusb particulière. J'ai fait cela via le mécanisme d'attente et de notification suivant basé sur la macro EM_ASYNC_JS:

EM_JS(void, em_libusb_notify, (void), {
  dispatchEvent(new Event("em-libusb"));
});

EM_ASYNC_JS(int, em_libusb_wait, (int timeout), {
  let onEvent, timeoutId;

  try {
    return await new Promise(resolve => {
      onEvent = () => resolve(0);
      addEventListener('em-libusb', onEvent);

      timeoutId = setTimeout(resolve, timeout, -1);
    });
  } finally {
    removeEventListener('em-libusb', onEvent);
    clearTimeout(timeoutId);
  }
});

La fonction em_libusb_notify() est utilisée chaque fois que libusb tente de signaler un événement, tel que la fin du transfert de données:

void usbi_signal_event(usbi_event_t *event)
{
  uint64_t dummy = 1;
  ssize_t r;

  r = write(EVENT_WRITE_FD(event), &dummy, sizeof(dummy));
  if (r != sizeof(dummy))
    usbi_warn(NULL, "event write failed");
#ifdef __EMSCRIPTEN__
  em_libusb_notify();
#endif
}

En attendant, la partie em_libusb_wait() permet de "réveiller" la mise en veille Asyncify lorsqu'un événement em-libusb est reçu ou que le délai avant expiration est écoulé:

double until_time = emscripten_get_now() + timeout_ms;
for (;;) {
  // Emscripten `poll` ignores timeout param, but pass 0 explicitly just
  // in case.
  num_ready = poll(fds, nfds, 0);
  if (num_ready != 0) break;
  int timeout = until_time - emscripten_get_now();
  if (timeout <= 0) break;
  int result = em_libusb_wait(timeout);
  if (result != 0) break;
}

En raison d'une réduction significative des temps de veille et de réveil, ce mécanisme a résolu les problèmes d'efficacité de l'implémentation précédente basée sur emscripten_sleep() et a augmenté le débit de la démonstration de l'appareil photo reflex numérique de 13 à 14 FPS à plus de 30 FPS de manière constante, ce qui est suffisant pour un flux en direct fluide.

Créer le système et effectuer le premier test

Une fois le backend terminé, j'ai dû l'ajouter à Makefile.am et configure.ac. Le seul élément intéressant ici est la modification des indicateurs spécifiques à Emscripten:

emscripten)
  AC_SUBST(EXEEXT, [.html])
  # Note: LT_LDFLAGS is not enough here because we need link flags for executable.
  AM_LDFLAGS="${AM_LDFLAGS} --bind -s ASYNCIFY -s ASSERTIONS -s ALLOW_MEMORY_GROWTH -s INVOKE_RUN=0 -s EXPORTED_RUNTIME_METHODS=['callMain']"
  ;;

Tout d'abord, les exécutables sur les plates-formes Unix n'ont généralement pas d'extension de fichier. Emscripten produit toutefois des résultats différents en fonction de l'extension que vous demandez. J'utilise AC_SUBST(EXEEXT, …) pour remplacer l'extension exécutable par .html afin que tout exécutable d'un package (tests et exemples) devienne un fichier HTML avec le shell par défaut d'Emscripten qui se charge de charger et d'instancier JavaScript et WebAssembly.

Deuxièmement, comme j'utilise Embind et Asyncify, je dois activer ces fonctionnalités (--bind -s ASYNCIFY) et autoriser la croissance dynamique de la mémoire (-s ALLOW_MEMORY_GROWTH) via les paramètres du linker. Malheureusement, une bibliothèque ne peut pas signaler ces indicateurs au linker. Par conséquent, chaque application qui utilise ce port libusb devra également ajouter les mêmes indicateurs d'association à sa configuration de compilation.

Enfin, comme indiqué précédemment, WebUSB exige que l'énumération des appareils soit effectuée via un geste utilisateur. Les exemples et les tests libusb supposent qu'ils peuvent énumérer les appareils au démarrage et échouent avec une erreur sans modification. J'ai dû désactiver l'exécution automatique (-s INVOKE_RUN=0) et exposer la méthode manuelle callMain() (-s EXPORTED_RUNTIME_METHODS=...).

Une fois tout cela terminé, je pouvais diffuser les fichiers générés avec un serveur Web statique, initialiser WebUSB et exécuter manuellement ces exécutables HTML à l'aide des outils de développement.

Capture d&#39;écran montrant une fenêtre Chrome avec les outils pour les développeurs ouverts sur une page &quot;testlibusb&quot; diffusée en local. La console DevTools évalue &quot;navigator.usb.requestDevice({ filters: [] })&quot;, ce qui a déclenché une invite d&#39;autorisation et demande actuellement à l&#39;utilisateur de choisir un appareil USB à partager avec la page. L&#39;appareil photo ILCE-6600 (Sony) est actuellement sélectionné.

Capture d&#39;écran de l&#39;étape suivante, avec les outils pour les développeurs toujours ouverts. Une fois l&#39;appareil sélectionné, la console a évalué une nouvelle expression &quot;Module.callMain([&#39;-v&#39;])&quot;, qui a exécuté l&#39;application &quot;testlibusb&quot; en mode verbeux. La sortie affiche diverses informations détaillées sur la caméra USB précédemment connectée: fabricant Sony, produit ILCE-6600, numéro de série, configuration, etc.

Cela peut sembler peu, mais lorsque vous portez des bibliothèques vers une nouvelle plate-forme, atteindre l'étape où elles produisent une sortie valide pour la première fois est très excitant.

Utiliser le port

Comme indiqué ci-dessus, le port dépend de quelques fonctionnalités Emscripten qui doivent actuellement être activées à l'étape d'association de l'application. Si vous souhaitez utiliser ce port libusb dans votre propre application, procédez comme suit:

  1. Téléchargez la dernière version de libusb en tant qu'archive dans le cadre de votre compilation ou ajoutez-la en tant que sous-module git dans votre projet.
  2. Exécutez autoreconf -fiv dans le dossier libusb.
  3. Exécutez emconfigure ./configure –host=wasm32 –prefix=/some/installation/path pour initialiser le projet pour la compilation croisée et définir un chemin d'accès dans lequel vous souhaitez placer les artefacts compilés.
  4. Exécutez emmake make install.
  5. Pointez votre application ou votre bibliothèque de niveau supérieur vers la recherche de la libusb sous le chemin d'accès choisi précédemment.
  6. Ajoutez les options suivantes aux arguments de liaison de votre application: --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH.

La bibliothèque présente actuellement quelques limites:

  • Impossible d'annuler un transfert. Il s'agit d'une limitation de WebUSB, qui, à son tour, découle de l'absence d'annulation de transfert multiplate-forme dans libusb elle-même.
  • Le transfert isochrone n'est pas pris en charge. Il ne devrait pas être difficile de l'ajouter en suivant l'implémentation des modes de transfert existants comme exemples. Toutefois, il s'agit également d'un mode assez rare et je ne disposais d'aucun appareil pour le tester. C'est pourquoi je l'ai laissé non pris en charge pour le moment. Si vous possédez de tels appareils et que vous souhaitez contribuer à la bibliothèque, les PR sont les bienvenus.
  • Les limites multiplates-formes mentionnées précédemment. Ces limites sont imposées par les systèmes d'exploitation. Nous ne pouvons donc pas faire grand-chose, sauf demander aux utilisateurs de remplacer le pilote ou les autorisations. Toutefois, si vous portez des appareils HID ou série, vous pouvez suivre l'exemple libusb et porter une autre bibliothèque vers une autre API Fugu. Par exemple, vous pouvez porter une bibliothèque C hidapi vers WebHID et éviter complètement ces problèmes associés à l'accès USB de bas niveau.

Conclusion

Dans cet article, j'ai montré comment, à l'aide des API Emscripten, Asyncify et Fugu, même des bibliothèques de bas niveau comme libusb peuvent être portées sur le Web avec quelques astuces d'intégration.

Le portage de ces bibliothèques de bas niveau essentielles et largement utilisées est particulièrement gratifiant, car il permet également de porter des bibliothèques de niveau supérieur ou même des applications complètes sur le Web. Les expériences auparavant limitées aux utilisateurs d'une ou de deux plates-formes sont désormais disponibles sur tous les types d'appareils et de systèmes d'exploitation, en un seul clic.

Dans le prochain article, je vous expliquerai comment créer la démonstration gPhoto2 Web, qui récupère non seulement les informations sur l'appareil, mais utilise également largement la fonctionnalité de transfert de libusb. En attendant, j'espère que l'exemple libusb vous a inspiré et que vous allez essayer la démonstration, jouer avec la bibliothèque elle-même, ou même essayer de porter une autre bibliothèque couramment utilisée vers l'une des API Fugu.