USB-Anwendungen ins Web portieren Teil 1: libusb

Hier erfahren Sie, wie Code, der mit externen Geräten interagiert, mit WebAssembly und Fugu APIs ins Web portiert werden kann.

In einem früheren Beitrag habe ich gezeigt, wie Sie Apps, die Dateisystem-APIs verwenden, mit der File System Access API, WebAssembly und Asyncify ins Web portieren können. Ich möchte nun das Thema der Integration von Fugu APIs mit WebAssembly und der Portierung von Apps ins Web fortsetzen, ohne wichtige Funktionen zu verlieren.

Ich zeige, wie Apps, die mit USB-Geräten kommunizieren, ins Web portiert werden können. Dazu portiere ich libusb, eine beliebte USB-Bibliothek, die in C geschrieben ist, zu WebAssembly (über Emscripten), Asyncify und WebUSB.

Das Wichtigste zuerst: Eine Demo

Das Wichtigste beim Portieren einer Bibliothek ist die Auswahl der richtigen Demo. Sie sollte die Funktionen der portierten Bibliothek präsentieren, Ihnen die Möglichkeit geben, sie auf verschiedene Weise zu testen, und gleichzeitig visuell ansprechend sein.

Ich habe mich für die Idee „DSLR-Fernbedienung“ entschieden. Insbesondere das Open-Source-Projekt gPhoto2 ist schon lange genug in diesem Bereich aktiv, um die Unterstützung für eine Vielzahl von Digitalkameras per Reverse Engineering zu implementieren. Es unterstützt mehrere Protokolle, aber am meisten interessierte mich die USB-Unterstützung, die über libusb erfolgt.

Ich beschreibe die Schritte zum Erstellen dieser Demo in zwei Teilen. In diesem Blogpost beschreibe ich, wie ich libusb selbst portiert habe und welche Tricks erforderlich sein könnten, um andere beliebte Bibliotheken auf Fugu APIs zu portieren. Im zweiten Beitrag gehe ich auf die Details der Portierung und Integration von gPhoto2 ein.

Am Ende hatte ich eine funktionierende Webanwendung, die eine Vorschau des Livefeeds einer DSLR anzeigt und deren Einstellungen über USB steuern kann. Sehen Sie sich die Live-Demo oder die aufgezeichnete Demo an, bevor Sie sich die technischen Details durchlesen:

Die Demo wird auf einem Laptop ausgeführt, der mit einer Sony-Kamera verbunden ist.

Hinweis zu kameraspezifischen Besonderheiten

Vielleicht ist Ihnen aufgefallen, dass es im Video eine Weile dauert, bis die Einstellungen geändert werden. Wie bei den meisten anderen Problemen, die auftreten können, liegt das nicht an der Leistung von WebAssembly oder WebUSB, sondern daran, wie gPhoto2 mit der für die Demo ausgewählten Kamera interagiert.

Die Sony a6600 stellt keine API bereit, um Werte wie ISO, Blende oder Belichtungszeit direkt festzulegen. Stattdessen bietet sie nur Befehle, um diese Werte um die angegebene Anzahl von Schritten zu erhöhen oder zu verringern. Erschwerend kommt hinzu, dass keine Liste der tatsächlich unterstützten Werte zurückgegeben wird. Die zurückgegebene Liste scheint für viele Sony-Kameramodelle hartcodiert zu sein.

Wenn einer dieser Werte festgelegt wird, hat gPhoto2 keine andere Wahl, als:

  1. Machen Sie einen Schritt (oder ein paar) in Richtung des gewählten Werts.
  2. Warte einen Moment, bis die Kamera die Einstellungen aktualisiert hat.
  3. Lies den Wert vor, auf dem die Kamera tatsächlich gelandet ist.
  4. Prüfen Sie, ob der letzte Schritt den gewünschten Wert nicht übersprungen hat und nicht am Ende oder Anfang der Liste umgebrochen wurde.
  5. Wiederholen.

Das kann einige Zeit dauern. Wenn der Wert von der Kamera unterstützt wird, wird er erreicht. Wenn nicht, wird der nächstgelegene unterstützte Wert verwendet.

Andere Kameras haben wahrscheinlich andere Einstellungen, zugrunde liegende APIs und Eigenheiten. Beachten Sie, dass gPhoto2 ein Open-Source-Projekt ist und es einfach nicht möglich ist, alle Kameramodelle automatisch oder manuell zu testen. Daher sind detaillierte Problemberichte und PRs immer willkommen. Achten Sie aber darauf, die Probleme zuerst mit dem offiziellen gPhoto2-Client zu reproduzieren.

Wichtige Hinweise zur plattformübergreifenden Kompatibilität

Leider wird unter Windows allen „bekannten“ Geräten, einschließlich DSLR-Kameras, ein Systemtreiber zugewiesen, der nicht mit WebUSB kompatibel ist. Wenn Sie die Demo unter Windows ausprobieren möchten, müssen Sie ein Tool wie Zadig verwenden, um den Treiber für die verbundene DSLR-Kamera entweder auf WinUSB oder libusb zu überschreiben. Dieser Ansatz funktioniert für mich und viele andere Nutzer gut, aber Sie sollten ihn auf eigene Gefahr verwenden.

Unter Linux müssen Sie wahrscheinlich benutzerdefinierte Berechtigungen festlegen, um den Zugriff auf Ihre DSLR über WebUSB zu ermöglichen. Dies hängt jedoch von Ihrer Distribution ab.

Unter macOS und Android sollte die Demo sofort funktionieren. Wenn Sie es auf einem Android-Smartphone ausprobieren, wechseln Sie in das Querformat, da ich mir nicht viel Mühe gegeben habe, es responsiv zu gestalten (PRs sind willkommen!):

Ein Android-Smartphone, das über ein USB‑C-Kabel mit einer Canon-Kamera verbunden ist.
Dieselbe Demo auf einem Android-Smartphone. Bild von Surma.

Eine ausführlichere Anleitung zur plattformübergreifenden Verwendung von WebUSB finden Sie im Abschnitt Plattformspezifische Überlegungen des Artikels „Building a device for WebUSB“.

Neues Backend zu libusb hinzufügen

Kommen wir nun zu den technischen Details. Es ist zwar möglich, eine Shim-API ähnlich wie libusb bereitzustellen (dies wurde bereits von anderen getan) und andere Anwendungen damit zu verknüpfen, aber dieser Ansatz ist fehleranfällig und erschwert die weitere Erweiterung oder Wartung. Ich wollte alles richtig machen, damit es in Zukunft möglicherweise wieder in libusb integriert werden kann.

Glücklicherweise steht in der README-Datei zu libusb Folgendes:

„libusb ist intern so abstrahiert, dass es hoffentlich auf andere Betriebssysteme portiert werden kann. Weitere Informationen finden Sie in der Datei PORTING.“

libusb ist so strukturiert, dass die öffentliche API von „Backends“ getrennt ist. Diese Back-Ends sind für das Auflisten, Öffnen, Schließen und die eigentliche Kommunikation mit den Geräten über die Low-Level-APIs des Betriebssystems verantwortlich. So abstrahiert libusb bereits Unterschiede zwischen Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku und Solaris und funktioniert auf all diesen Plattformen.

Ich musste ein weiteres Backend für das „Betriebssystem“ Emscripten+WebUSB hinzufügen. Die Implementierungen für diese Backends befinden sich im Ordner 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

Jedes Backend enthält den Header libusbi.h mit gängigen Typen und Hilfsprogrammen und muss eine usbi_backend-Variable vom Typ usbi_os_backend bereitstellen. So sieht beispielsweise das Windows-Backend aus:

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

Wenn wir uns die Eigenschaften ansehen, stellen wir fest, dass die Struktur den Backend-Namen, eine Reihe von Funktionen, Handler für verschiedene Low-Level-USB-Vorgänge in Form von Funktionszeigern und schließlich die Größen enthält, die für die Speicherung privater Daten auf Geräte-, Kontext- und Übertragungsebene zugewiesen werden müssen.

Die privaten Datenfelder sind zumindest nützlich, um Betriebssystem-Handles für all diese Dinge zu speichern, da wir ohne Handles nicht wissen, auf welches Element sich ein bestimmter Vorgang bezieht. In der Webimplementierung wären die Betriebssystem-Handles die zugrunde liegenden WebUSB-JavaScript-Objekte. Die natürliche Art, sie in Emscripten darzustellen und zu speichern, ist die Klasse emscripten::val, die als Teil von Embind (dem Bindungssystem von Emscripten) bereitgestellt wird.

Die meisten Back-Ends im Ordner sind in C implementiert, einige jedoch in C++. Embind funktioniert nur mit C++. Die Entscheidung wurde also für mich getroffen und ich habe libusb/libusb/os/emscripten_webusb.cpp mit der erforderlichen Struktur und mit sizeof(val) für die privaten Datenfelder hinzugefügt:

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

WebUSB-Objekte als Geräte-Handles speichern

libusb stellt einsatzbereite Zeiger auf den zugewiesenen Bereich für private Daten bereit. Damit diese Zeiger als val-Instanzen verwendet werden können, habe ich kleine Hilfsprogramme hinzugefügt, die sie direkt erstellen, als Referenzen abrufen und Werte verschieben:

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

Asynchrone Web-APIs in synchronen C-Kontexten

Jetzt brauchte er eine Möglichkeit, asynchrone WebUSB APIs zu verarbeiten, bei denen libusb synchrone Vorgänge erwartet. Dafür könnte ich Asyncify verwenden, genauer gesagt die Embind-Integration über val::await().

Ich wollte auch WebUSB-Fehler korrekt verarbeiten und in libusb-Fehlercodes konvertieren. Embind bietet derzeit jedoch keine Möglichkeit, JavaScript-Ausnahmen oder Promise-Ablehnungen von der C++-Seite zu verarbeiten. Dieses Problem lässt sich umgehen, indem eine Ablehnung auf der JavaScript-Seite abgefangen und das Ergebnis in ein { error, value }-Objekt konvertiert wird, das dann auf der C++-Seite sicher geparst werden kann. Dazu habe ich das Makro EM_JS und die Emval.to{Handle, Value} APIs kombiniert:

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

Jetzt kann ich promise_result::await() für alle Promise verwenden, die von WebUSB-Vorgängen zurückgegeben werden, und die Felder error und value separat prüfen.

Wenn Sie beispielsweise ein val, das ein USBDevice darstellt, aus libusb_device_handle abrufen, die Methode open() aufrufen, auf das Ergebnis warten und einen Fehlercode als libusb-Statuscode zurückgeben, sieht das so aus:

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

Geräteaufzählung

Bevor ich ein Gerät öffnen kann, muss libusb natürlich eine Liste der verfügbaren Geräte abrufen. Das Backend muss diesen Vorgang über einen get_device_list-Handler implementieren.

Die Schwierigkeit besteht darin, dass es im Gegensatz zu anderen Plattformen aus Sicherheitsgründen keine Möglichkeit gibt, alle verbundenen USB-Geräte im Web aufzulisten. Stattdessen wird der Ablauf in zwei Teile aufgeteilt. Zuerst fordert die Webanwendung über navigator.usb.requestDevice() Geräte mit bestimmten Eigenschaften an. Der Nutzer wählt dann manuell aus, welches Gerät er freigeben möchte, oder lehnt die Berechtigungsanfrage ab. Anschließend werden in der Anwendung die bereits genehmigten und verbundenen Geräte über navigator.usb.getDevices() aufgelistet.

Zuerst habe ich versucht, requestDevice() direkt in der Implementierung des get_device_list-Handlers zu verwenden. Das Anzeigen einer Berechtigungsaufforderung mit einer Liste verbundener Geräte gilt jedoch als sensibler Vorgang und muss durch eine Nutzerinteraktion (z. B. einen Klick auf eine Schaltfläche auf einer Seite) ausgelöst werden. Andernfalls wird immer ein abgelehnter Promise zurückgegeben. libusb-Anwendungen müssen die verbundenen Geräte oft beim Start der Anwendung auflisten, daher war die Verwendung von requestDevice() keine Option.

Stattdessen musste ich den Aufruf von navigator.usb.requestDevice() dem Endentwickler überlassen und nur die bereits genehmigten Geräte aus navigator.usb.getDevices() verfügbar machen:

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

Der Großteil des Backend-Codes verwendet val und promise_result auf ähnliche Weise wie oben gezeigt. Es gibt noch ein paar weitere interessante Hacks im Code für die Datenübertragung, aber diese Implementierungsdetails sind für die Zwecke dieses Artikels weniger wichtig. Sehen Sie sich bei Interesse den Code und die Kommentare auf GitHub an.

Ereignisschleifen ins Web portieren

Ein weiterer Teil des libusb-Ports, den ich besprechen möchte, ist die Ereignisbehandlung. Wie im vorherigen Artikel beschrieben, sind die meisten APIs in Systemsprachen wie C synchron. Das gilt auch für die Ereignisbehandlung. Sie wird normalerweise über eine Endlosschleife implementiert, die eine Reihe externer E/A-Quellen „abfragt“ (versucht, Daten zu lesen oder die Ausführung blockiert, bis Daten verfügbar sind). Wenn mindestens eine dieser Quellen antwortet, wird dies als Ereignis an den entsprechenden Handler übergeben. Sobald der Handler fertig ist, kehrt die Steuerung zur Schleife zurück und pausiert für eine weitere Abfrage.

Dieser Ansatz hat im Web einige Probleme.

Erstens: WebUSB stellt keine Roh-Handles der zugrunde liegenden Geräte bereit und kann dies auch nicht. Daher ist es nicht möglich, diese direkt abzufragen. Zweitens verwendet libusb die APIs eventfd und pipe für andere Ereignisse sowie für die Verarbeitung von Übertragungen auf Betriebssystemen ohne Rohgeräte-Handles. eventfd wird derzeit jedoch nicht in Emscripten unterstützt und pipe entspricht derzeit nicht der Spezifikation und kann nicht auf Ereignisse warten.

Das größte Problem ist, dass das Web eine eigene Ereignisschleife hat. Diese globale Ereignisschleife wird für alle externen E/A-Vorgänge (einschließlich fetch(), Timer oder in diesem Fall WebUSB) verwendet und ruft Ereignis- oder Promise-Handler auf, wenn die entsprechenden Vorgänge abgeschlossen sind. Wenn eine weitere, verschachtelte, unendliche Ereignisschleife ausgeführt wird, wird die Ereignisschleife des Browsers blockiert und kann nicht fortgesetzt werden. Das bedeutet, dass nicht nur die Benutzeroberfläche nicht mehr reagiert, sondern auch, dass der Code nie Benachrichtigungen für die E/A-Ereignisse erhält, auf die er wartet. Das führt in der Regel zu einem Deadlock. Genau das ist auch passiert, als ich versucht habe, libusb in einer Demo zu verwenden. Die Seite ist eingefroren.

Wie bei anderen blockierenden E/A-Vorgängen müssen Entwickler auch bei der Portierung solcher Ereignisschleifen ins Web eine Möglichkeit finden, diese Schleifen auszuführen, ohne den Hauptthread zu blockieren. Eine Möglichkeit besteht darin, die Anwendung so zu refaktorieren, dass E/A-Ereignisse in einem separaten Thread verarbeitet und die Ergebnisse an den Hauptthread zurückgegeben werden. Die andere Möglichkeit besteht darin, die Schleife mit Asyncify zu pausieren und nicht blockierend auf Ereignisse zu warten.

Ich wollte weder an libusb noch an gPhoto2 wesentliche Änderungen vornehmen und hatte Asyncify bereits für die Promise-Integration verwendet. Also habe ich mich für diesen Weg entschieden. Um eine blockierende Variante von poll() zu simulieren, habe ich für den ersten Proof of Concept eine Schleife verwendet, wie unten gezeigt:

#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

Funktionen:

  1. Ruft poll() auf, um zu prüfen, ob bereits Ereignisse vom Backend gemeldet wurden. Wenn ja, wird die Schleife beendet. Andernfalls wird die Emscripten-Implementierung von poll() sofort mit 0 zurückgegeben.
  2. Ruft emscripten_sleep(0) auf. Diese Funktion verwendet im Hintergrund Asyncify und setTimeout() und wird hier verwendet, um die Kontrolle an die Haupt-Ereignisschleife des Browsers zurückzugeben. So kann der Browser alle Nutzerinteraktionen und E/A-Ereignisse verarbeiten, einschließlich WebUSB.
  3. Prüfen Sie, ob das angegebene Zeitlimit bereits abgelaufen ist. Wenn nicht, setzen Sie die Schleife fort.

Wie im Kommentar erwähnt, war dieser Ansatz nicht optimal, da der gesamte Aufrufstapel mit Asyncify immer wieder gespeichert und wiederhergestellt wurde, auch wenn noch keine USB-Ereignisse zu verarbeiten waren (was meistens der Fall ist), und weil setTimeout() selbst in modernen Browsern eine Mindestdauer von 4 ms hat. Dennoch hat es gut genug funktioniert, um im Proof of Concept einen Livestream mit 13–14 fps von der DSLR zu erzeugen.

Später habe ich mich entschieden, die Funktion mithilfe des Browser-Ereignissystems zu verbessern. Es gibt mehrere Möglichkeiten, diese Implementierung weiter zu verbessern. Ich habe mich jedoch dafür entschieden, benutzerdefinierte Ereignisse direkt im globalen Objekt auszugeben, ohne sie mit einer bestimmten libusb-Datenstruktur zu verknüpfen. Ich habe das mit dem folgenden Warte- und Benachrichtigungsmechanismus getan, der auf dem EM_ASYNC_JS-Makro basiert:

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

Die Funktion em_libusb_notify() wird immer dann verwendet, wenn libusb versucht, ein Ereignis zu melden, z. B. den Abschluss einer Datenübertragung:

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
}

Der em_libusb_wait()-Teil wird verwendet, um aus dem Asyncify-Ruhezustand „aufzuwachen“, wenn entweder ein em-libusb-Ereignis empfangen wird oder das Zeitlimit abgelaufen ist:

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

Durch die deutliche Reduzierung von Ruhe- und Aktivierungsphasen wurden die Effizienzprobleme der früheren emscripten_sleep()-basierten Implementierung behoben und der Durchsatz der DSLR-Demo von 13–14 fps auf konsistente 30+ fps erhöht, was für einen flüssigen Livefeed ausreicht.

Build-System und erster Test

Nachdem das Backend fertig war, musste ich es zu Makefile.am und configure.ac hinzufügen. Der einzige interessante Teil hier ist die Änderung der Emscripten-spezifischen Flags:

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']"
  ;;

Erstens haben ausführbare Dateien auf Unix-Plattformen normalerweise keine Dateiendungen. Emscripten erzeugt jedoch je nach angeforderter Erweiterung eine andere Ausgabe. Ich verwende AC_SUBST(EXEEXT, …), um die ausführbare Erweiterung in .html zu ändern, sodass jede ausführbare Datei in einem Paket – Tests und Beispiele – zu einer HTML-Datei mit der Standard-Shell von Emscripten wird, die das Laden und Instanziieren von JavaScript und WebAssembly übernimmt.

Zweitens muss ich, da ich Embind und Asyncify verwende, diese Funktionen (--bind -s ASYNCIFY) aktivieren und über Linker-Parameter ein dynamisches Speicherwachstum (-s ALLOW_MEMORY_GROWTH) zulassen. Leider gibt es für eine Bibliothek keine Möglichkeit, diese Flags an den Linker zu melden. Daher muss jede Anwendung, die diesen libusb-Port verwendet, dieselben Linker-Flags auch in ihre Build-Konfiguration einfügen.

Wie bereits erwähnt, erfordert WebUSB, dass die Geräteaufzählung über eine Nutzergeste erfolgt. libusb-Beispiele und ‑Tests gehen davon aus, dass sie Geräte beim Start aufzählen können, und schlagen ohne Änderungen mit einem Fehler fehl. Stattdessen musste ich die automatische Ausführung (-s INVOKE_RUN=0) deaktivieren und die manuelle callMain()-Methode (-s EXPORTED_RUNTIME_METHODS=...) verfügbar machen.

Anschließend konnte ich die generierten Dateien mit einem statischen Webserver bereitstellen, WebUSB initialisieren und diese HTML-Ausführbaren manuell mithilfe der Entwicklertools ausführen.

Screenshot eines Chrome-Fensters mit geöffneten Entwicklertools auf einer lokal bereitgestellten „testlibusb“-Seite. In der DevTools-Konsole wird „navigator.usb.requestDevice({ filters: [] })“ ausgewertet. Dadurch wurde eine Berechtigungsaufforderung ausgelöst, in der der Nutzer aufgefordert wird, ein USB-Gerät auszuwählen, das für die Seite freigegeben werden soll. ILCE-6600 (eine Sony-Kamera) ist derzeit ausgewählt.

Screenshot des nächsten Schritts, wobei die Entwicklertools noch geöffnet sind. Nachdem das Gerät ausgewählt wurde, hat die Console einen neuen Ausdruck „Module.callMain(['-v'])“ ausgewertet, der die App „testlibusb“ im ausführlichen Modus ausgeführt hat. Die Ausgabe enthält verschiedene detaillierte Informationen zur zuvor verbundenen USB-Kamera: Hersteller Sony, Produkt ILCE-6600, Seriennummer, Konfiguration usw.

Das sieht nicht nach viel aus, aber wenn man Bibliotheken auf eine neue Plattform portiert, ist es ziemlich aufregend, wenn man zum ersten Mal eine gültige Ausgabe erhält.

Port verwenden

Wie oben erwähnt, hängt die Portierung von einigen Emscripten-Funktionen ab, die derzeit in der Verknüpfungsphase der Anwendung aktiviert werden müssen. Wenn Sie diesen libusb-Port in Ihrer eigenen Anwendung verwenden möchten, müssen Sie Folgendes tun:

  1. Lade die aktuelle libusb entweder als Archiv im Rahmen deines Builds herunter oder füge sie als Git-Submodul in dein Projekt ein.
  2. Führen Sie autoreconf -fiv im Ordner libusb aus.
  3. Führen Sie emconfigure ./configure –host=wasm32 –prefix=/some/installation/path aus, um das Projekt für die Cross-Kompilierung zu initialisieren und einen Pfad festzulegen, in dem die erstellten Artefakte abgelegt werden sollen.
  4. Führen Sie emmake make install aus.
  5. Verweisen Sie Ihre Anwendung oder Bibliothek auf höherer Ebene auf die Suche nach der libusb unter dem zuvor ausgewählten Pfad.
  6. Fügen Sie den Linkargumenten Ihrer Anwendung die folgenden Flags hinzu: --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH.

Die Bibliothek hat derzeit einige Einschränkungen:

  • Keine Unterstützung für die Stornierung von Übertragungen. Dies ist eine Einschränkung von WebUSB, die wiederum auf das Fehlen einer plattformübergreifenden Übertragungsstornierung in libusb selbst zurückzuführen ist.
  • Keine Unterstützung für isochrone Übertragung. Es sollte nicht schwierig sein, ihn hinzuzufügen, wenn man sich an der Implementierung vorhandener Übertragungsmodi orientiert. Es ist jedoch ein eher seltener Modus und ich hatte keine Geräte, um ihn zu testen. Daher habe ich ihn vorerst als nicht unterstützt belassen. Wenn Sie solche Geräte haben und zur Bibliothek beitragen möchten, sind PRs willkommen.
  • Die zuvor erwähnten plattformübergreifenden Einschränkungen. Diese Einschränkungen werden von den Betriebssystemen auferlegt. Wir können hier also nicht viel tun, außer die Nutzer zu bitten, den Treiber oder die Berechtigungen zu überschreiben. Wenn Sie jedoch HID- oder serielle Geräte portieren, können Sie dem libusb-Beispiel folgen und eine andere Bibliothek zu einer anderen Fugu API portieren. Sie könnten beispielsweise die C-Bibliothek hidapi zu WebHID portieren und so die Probleme im Zusammenhang mit dem Low-Level-USB-Zugriff vollständig umgehen.

Fazit

In diesem Beitrag habe ich gezeigt, wie sich mit Emscripten, Asyncify und Fugu APIs sogar Low-Level-Bibliotheken wie libusb mit ein paar Integrationstricks ins Web portieren lassen.

Die Portierung solcher wichtigen und weit verbreiteten Low-Level-Bibliotheken ist besonders lohnend, da dadurch auch High-Level-Bibliotheken oder sogar ganze Anwendungen ins Web gebracht werden können. So werden Funktionen, die zuvor auf Nutzer einer oder zwei Plattformen beschränkt waren, für alle Arten von Geräten und Betriebssystemen verfügbar. Sie sind nur einen Klick entfernt.

Im nächsten Beitrag zeige ich Ihnen die Schritte zum Erstellen der Web-gPhoto2-Demo, die nicht nur Geräteinformationen abruft, sondern auch die Übertragungsfunktion von libusb umfassend nutzt. In der Zwischenzeit hoffe ich, dass Sie das libusb-Beispiel inspirierend fanden und die Demo ausprobieren, mit der Bibliothek selbst experimentieren oder vielleicht sogar eine andere weit verbreitete Bibliothek auf eine der Fugu APIs portieren.