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 übertragen werden kann.

In einem vorherigen Beitrag habe ich gezeigt, wie Apps, die Dateisystem-APIs verwenden, mithilfe der File System Access API, WebAssembly und Asyncify ins Web übertragen werden. Jetzt möchte ich mit der Integration von Fugu APIs in WebAssembly und der Portierung von Apps ins Web fortfahren, ohne wichtige Funktionen zu verlieren.

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

Das Wichtigste zuerst: eine Demo

Das Wichtigste bei der Portierung einer Bibliothek ist die Auswahl der richtigen Demo. Damit können Sie die Funktionen der portierten Bibliothek präsentieren, Sie können sie auf verschiedene Arten testen und gleichzeitig visuell ansprechend gestalten.

Ich habe mich für die Fernbedienung einer digitalen Spiegelreflexkamera entschieden. Insbesondere das Open-Source-Projekt gPhoto2 befindet sich schon lange genug in diesem Bereich, um eine Vielzahl von Digitalkameras zurückzuentwickeln und die Unterstützung zu implementieren. Es unterstützt mehrere Protokolle, aber am meisten hat mich der USB-Support genutzt, der über libusb durchgeführt wird.

Ich beschreibe die Schritte zum Erstellen dieser Demo in zwei Teilen. In diesem Blogpost erkläre ich, wie ich libusb selbst portiert habe und welche Tricks notwendig sein könnten, um andere beliebte Bibliotheken auf Fugu APIs zu übertragen. Im zweiten Post geht es um die Portierung und Integration von gPhoto2.

Letztendlich habe ich eine funktionierende Webanwendung, die eine Vorschau des Livefeeds einer digitalen Spiegelreflexkamera anzeigt und die Einstellungen über USB steuern kann. Sie können sich gern die Live-Demo oder die aufgezeichnete Demo ansehen, bevor Sie sich mit den technischen Details befassen:

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

Hinweis zu kameraspezifischen Eigenheiten

Vielleicht ist dir schon aufgefallen, dass das Ändern der Einstellungen im Video eine Weile dauert. Wie bei den meisten anderen Problemen, die Sie möglicherweise sehen, wird dies nicht durch die Leistung von WebAssembly oder WebUSB verursacht, sondern durch die Interaktion von gPhoto2 mit der für die Demo ausgewählten Kamera.

Das Sony a6600 stellt keine API zur direkten Einstellung von Werten wie ISO, Blende oder Belichtungszeit zur Verfügung, sondern stellt lediglich Befehle zur Erhöhung oder Verringerung der Werte um die angegebene Anzahl von Schritten bereit. Um die Sache komplizierter zu machen, wird auch keine Liste der tatsächlich unterstützten Werte zurückgegeben – die zurückgegebene Liste scheint bei vielen Sony-Kameramodellen hartcodiert zu sein.

Wenn Sie einen dieser Werte festlegen, hat gPhoto2 keine andere Wahl, als:

  1. Machen Sie einen oder mehrere Schritte in Richtung des ausgewählten Werts.
  2. Warten Sie einen Moment, bis die Kamera die Einstellungen aktualisiert hat.
  3. Lesen Sie den Wert der Kamera noch einmal durch.
  4. Achten Sie darauf, dass der letzte Schritt nicht über den gewünschten Wert springt und nicht um das Ende oder den Anfang der Liste gelegt wurde.
  5. Wiederholen.

Es kann einige Zeit dauern, aber wenn der Wert tatsächlich von der Kamera unterstützt wird, erreicht er diese Position. Falls nicht, hält er beim nächsten unterstützten Wert an.

Andere Kameras haben wahrscheinlich andere Einstellungen, zugrunde liegende APIs und Eigenarten. Denken Sie daran, dass gPhoto2 ein Open-Source-Projekt ist und dass entweder automatisierte oder manuelle Tests aller Kameramodelle auf dem Markt nicht realisierbar sind. Daher sind detaillierte Problemberichte und PRs immer willkommen. Achten Sie aber darauf, die Probleme zuerst mit dem offiziellen gFoto2-Kunden zu reproduzieren.

Wichtige Hinweise zur plattformübergreifenden Kompatibilität

Leider ist 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 testen 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 gut für mich und viele andere Nutzer, Sie sollten ihn jedoch auf eigenes Risiko verwenden.

Unter Linux musst du wahrscheinlich benutzerdefinierte Berechtigungen festlegen, um den Zugriff auf deine DSLR über WebUSB zu ermöglichen. Das hängt jedoch von deiner Distribution ab.

Unter macOS und Android sollte die Demo sofort funktionieren. Wenn du die Funktion auf einem Android-Smartphone ausprobiert hast, solltest du unbedingt in das Querformat wechseln, da ich nicht viel Mühe gegeben habe, sie responsiv zu machen (PR-Mitarbeiter sind willkommen!):

Ein Android-Smartphone, das über ein USB-C-Kabel mit einer Canon-Kamera verbunden ist.
Die gleiche Demo läuft auf einem Android-Smartphone. Bild von Surma.

Einen ausführlichen Leitfaden zur plattformübergreifenden Nutzung von WebUSB finden Sie im Abschnitt „Plattformspezifische Überlegungen“ des Artikels „Geräte für WebUSB entwickeln“.

Neues Back-End zu libusb hinzufügen

Kommen wir nun zu den technischen Details. Es ist zwar möglich, eine Shim-API ähnlich libusb bereitzustellen (dies wurde bereits von anderen gemacht) und andere Anwendungen damit zu verknüpfen, dieser Ansatz ist jedoch fehleranfällig und erschwert weitere Erweiterungen oder Wartungen. Ich wollte alles richtig machen, und zwar auf eine Weise, die in Zukunft potenziell bei der Einbindung in libusb berücksichtigt werden könnte.

Glücklicherweise steht in der libusb-Readme-Datei:

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

libusb ist so strukturiert, dass die öffentliche API von den "Back-Ends" getrennt ist. Diese Backends sind verantwortlich für das Auflisten, Öffnen, Schließen und die tatsächliche Kommunikation mit den Geräten über die Low-Level-APIs des Betriebssystems. 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 Back-End für das Betriebssystem Emscripten+WebUSB hinzufügen. Die Implementierungen für diese Back-Ends 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 Back-End enthält den libusbi.h-Header mit gängigen Typen und Hilfsfunktionen und muss eine usbi_backend-Variable vom Typ usbi_os_backend verfügbar machen. So sieht beispielsweise das Windows-Back-End 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 die Eigenschaften betrachten, sehen wir, dass die Struktur den Back-End-Namen, eine Reihe ihrer Funktionen, Handler für verschiedene Low-Level-USB-Vorgänge in Form von Funktionszeigern und schließlich die Größen enthält, die zum Speichern privater Geräte-/Kontext-/Übertragungsdaten zugewiesen werden sollen.

Die privaten Datenfelder sind zumindest zum Speichern von Betriebssystem-Handles für all diese Dinge nützlich, da wir ohne Handles nicht wissen, für welches Element ein bestimmter Vorgang gilt. In der Webimplementierung wären die Betriebssystem-Handles die zugrunde liegenden WebUSB JavaScript-Objekte. Natürlich werden sie in Emscripten mithilfe der Klasse emscripten::val dargestellt und gespeichert, die als Teil von Embind (Bindungssystem von Emscripten) bereitgestellt wird.

Die meisten Backends in dem Ordner sind in C implementiert, einige jedoch in C++. Embind funktioniert nur mit C++. Diese Entscheidung wurde von mir 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 sofort einsatzbereite Zeiger auf den zugewiesenen Bereich für private Daten bereit. Damit ich mit diesen Verweisen als val-Instanzen arbeiten kann, habe ich kleine Hilfsprogramme hinzugefügt, die sie direkt erstellen, sie als Verweise 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))) {}
};

Async Web APIs in synchronen C-Kontexten

Jetzt musste eine Möglichkeit gefunden werden, asynchrone WebUSB APIs zu verarbeiten, bei denen libusb synchrone Vorgänge erwartet. Dazu kann ich Asyncify oder genauer die Embind-Integration über val::await() verwenden.

Außerdem wollte ich WebUSB-Fehler richtig verarbeiten und in Libusb-Fehlercodes umwandeln, aber Embind hat derzeit keine Möglichkeit, JavaScript-Ausnahmen oder Promise-Ablehnungen von C++-Seiten zu verarbeiten. Dieses Problem lässt sich umgehen, indem Sie eine Ablehnung auf der JavaScript-Seite erkennen und das Ergebnis in ein { error, value }-Objekt konvertieren, das jetzt sicher von der C++-Seite geparst werden kann. Dazu habe ich das EM_JS-Makro 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 könnte 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 ein val abgerufen wird, der ein USBDevice aus libusb_device_handle darstellt, seine open()-Methode aufruft, auf sein Ergebnis wartet und einen Fehlercode als libusb-Statuscode zurückgibt, 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äteliste

Bevor ich ein Gerät öffnen kann, muss libusb natürlich eine Liste der verfügbaren Geräte abrufen. Das Back-End 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 unterteilt. Zuerst fordert die Webanwendung Geräte mit bestimmten Eigenschaften über navigator.usb.requestDevice() an und der Nutzer wählt manuell aus, welches Gerät angezeigt werden soll, oder lehnt die Berechtigungsaufforderung 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. Die Anzeige einer Berechtigungsaufforderung mit einer Liste verbundener Geräte gilt jedoch als sensibel und muss durch eine Nutzerinteraktion (z. B. ein Klick auf eine Schaltfläche auf einer Seite) ausgelöst werden. Andernfalls wird immer ein abgelehntes Promise zurückgegeben. Libusb-Anwendungen möchten möglicherweise die verbundenen Geräte beim Start der App auflisten, sodass die Verwendung von requestDevice() keine Option war.

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

// 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 größte Teil des Back-End-Codes verwendet val und promise_result auf ähnliche Weise, wie bereits oben gezeigt. Der Code für die Verarbeitung von Datenübertragungen enthält einige weitere interessante Hacks, 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 mitnehmen

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, sodass die Ereignisverarbeitung keine Ausnahme darstellt. Sie wird normalerweise über eine Endlosschleife implementiert, die von einer Reihe externer E/A-Quellen „abfragt“ (versuchen, Daten zu lesen oder die Ausführung blockiert, bis einige Daten verfügbar sind) und, wenn mindestens eine dieser Quellen antwortet, dies als Ereignis an den entsprechenden Handler übergibt. Sobald der Handler abgeschlossen ist, kehrt das Steuerelement zur Schleife zurück und wird für eine weitere Abfrage angehalten.

Bei diesem Ansatz im Web gibt es einige Probleme.

Erstens gibt WebUSB keine unbearbeiteten Handles der zugrunde liegenden Geräte an und kann diese auch nicht offenlegen. Daher ist ein direktes Abfragen dieser Geräte keine Option. Zweitens verwendet libusb die APIs eventfd und pipe für andere Ereignisse sowie für die Verarbeitung von Übertragungen auf Betriebssystemen ohne unformatierte Geräte-Handles. eventfd wird derzeit in Emscripten nicht unterstützt. pipe wird zwar unterstützt, entspricht derzeit nicht den Spezifikationen und kann nicht auf Ereignisse warten.

Das größte Problem ist schließlich, dass das Web eine eigene Ereignisschleife hat. Diese globale Ereignisschleife wird für alle externen E/A-Vorgänge verwendet (einschließlich fetch(), Timern oder, in diesem Fall, WebUSB) und ruft Event- oder Promise-Handler auf, wenn die entsprechenden Vorgänge abgeschlossen sind. Durch die Ausführung einer weiteren verschachtelten, unendlichen Ereignisschleife wird die Ereignisschleife des Browsers blockiert. Das bedeutet, dass nicht nur die Benutzeroberfläche nicht mehr reagiert, sondern der Code nie Benachrichtigungen für dieselben E/A-Ereignisse erhält, auf die er wartet. Dies führt normalerweise zu einem Deadlock, was auch passiert ist, als ich versucht habe, libusb in einer Demo zu verwenden. Die Seite ist hängengeblieben.

Wie bei anderen blockierenden E/A-Vorgängen müssen Entwickler eine Möglichkeit finden, diese Schleifen auszuführen, um solche Ereignisschleifen ins Web zu übertragen, 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 besteht darin, mit Asyncify die Schleife zu pausieren und in nicht blockierender Weise auf Ereignisse zu warten.

Ich wollte weder an libusb noch an g Photo2 Änderungen vornehmen. Ich habe Asyncify bereits für die Promise-Integration verwendet, also habe ich diesen Weg gewählt. Ich habe für den ersten Proof of Concept eine Schleife verwendet, um eine blockierende Variante von poll() zu simulieren:

#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

Das passiert:

  1. Ruft poll() auf, um zu prüfen, ob noch Ereignisse vom Back-End gemeldet wurden. Falls es einige gibt, stoppt die Schleife. Andernfalls wird die Implementierung von poll() durch Emscripten sofort mit 0 zurückgegeben.
  2. Ruft emscripten_sleep(0) auf. Diese Funktion nutzt Asyncify und setTimeout() im Hintergrund und wird hier verwendet, um die Kontrolle an die Hauptereignisschleife des Browsers zurückzugeben. Dadurch kann der Browser alle Nutzerinteraktionen und E/A-Ereignisse verarbeiten, einschließlich WebUSB.
  3. Prüfen Sie, ob das angegebene Zeitlimit noch abgelaufen ist, und fahren Sie andernfalls mit der Schleife fort.

Wie im Kommentar erwähnt, war dieser Ansatz nicht optimal, da der gesamte Aufrufstack mit Asyncify weiterhin wiederhergestellt wurde, selbst wenn noch keine USB-Ereignisse verarbeitet werden mussten (was die meiste Zeit ist) und weil setTimeout() selbst in modernen Browsern eine Mindestdauer von 4 ms hat. Es lief dennoch gut genug, um im Proof of Concept einen Livestream mit einer DSLR-Kamera mit 13 bis 14 fps zu produzieren.

Später entschied ich mich, es durch die Nutzung des Browser-Ereignissystems zu verbessern. Es gibt mehrere Möglichkeiten, wie diese Implementierung weiter verbessert werden könnte, aber vorerst habe ich mich dafür entschieden, benutzerdefinierte Ereignisse direkt auf das globale Objekt auszugeben, ohne sie mit einer bestimmten Libusb-Datenstruktur zu verknüpfen. Dazu verwende ich den folgenden Warte- und Benachrichtigungsmechanismus basierend auf dem EM_ASYNC_JS-Makro:

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 dazu verwendet, den Ruhemodus zu beenden, 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;
}

Aufgrund der deutlichen Reduzierung der Schlaf- und Aufwachzeiten konnten mit diesem Mechanismus 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 werden, was für einen reibungslosen Livefeed ausreicht.

Build-System und erster Test

Nachdem das Back-End fertig war, musste ich es zu Makefile.am und configure.ac hinzufügen. Der einzig interessante Teil 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 Dateiendung. 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 einem HTML-Code mit der Standard-Shell von Emscripten wird, der das Laden und Instanziieren von JavaScript und WebAssembly übernimmt.

Zweitens: Da ich Embind und Asyncify verwende, muss ich diese Funktionen aktivieren (--bind -s ASYNCIFY) sowie dynamisches Arbeitsspeicherwachstum (-s ALLOW_MEMORY_GROWTH) über Verknüpfungsparameter zulassen. Leider gibt es keine Möglichkeit für eine Bibliothek, diese Flags an den Verknüpfungsdienst zu melden. Daher müssen für jede Anwendung, die diesen Libusb-Port verwendet, dieselben Verknüpfungs-Flags auch in ihre Build-Konfiguration aufgenommen werden.

Wie bereits erwähnt, ist für WebUSB eine Geräteauflistung über eine Nutzergeste erforderlich. Bei libusb-Beispielen und -Tests wird davon ausgegangen, dass Geräte beim Start aufgelistet werden können, was ohne Änderungen fehlschlägt. Stattdessen musste ich die automatische Ausführung deaktivieren (-s INVOKE_RUN=0) und die manuelle callMain()-Methode (-s EXPORTED_RUNTIME_METHODS=...) verwenden.

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

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

Screenshot des nächsten Schritts, die Entwicklertools sind noch geöffnet. Nach der Auswahl des Geräts hat die Console den neuen Ausdruck „Module.callMain([&#39;-v&#39;])“ ausgewertet, mit dem die App „testlibusb“ im ausführlichen Modus ausgeführt wurde. Die Ausgabe enthält verschiedene detaillierte Informationen zur zuvor angeschlossenen USB-Kamera: Hersteller Sony, Produkt ILCE-6600, Seriennummer, Konfiguration usw.

Es sieht nicht nach viel aus, aber wenn Sie Bibliotheken auf eine neue Plattform übertragen, ist es ziemlich aufregend, zum ersten Mal eine gültige Ausgabe zu erhalten.

Port verwenden

Wie oben erwähnt, hängt der Port 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. Laden Sie die neueste Version von libusb entweder als Archiv im Rahmen Ihres Builds herunter oder fügen Sie sie Ihrem Projekt als Git-Submodul hinzu.
  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 Kreuzkompilierung zu initialisieren und einen Pfad festzulegen, in dem die erstellten Artefakte gespeichert werden sollen.
  4. Führen Sie emmake make install aus.
  5. Lassen Sie Ihre Anwendung oder Ihre übergeordnete Bibliothek unter dem zuvor ausgewählten Pfad nach libusb suchen.
  6. Fügen Sie den Linkargumenten Ihrer Anwendung die folgenden Flags hinzu: --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH.

Für die Bibliothek gelten derzeit einige Einschränkungen:

  • Die Stornierung von Übertragungen wird nicht unterstützt. Dies ist eine Einschränkung von WebUSB, die wiederum auf das Fehlen einer plattformübergreifenden Übertragungsabbruch in libusb selbst zurückzuführen ist.
  • Die isochrone Übertragung wird nicht unterstützt. Es sollte nicht schwierig sein, ihn hinzuzufügen, indem Sie der Implementierung vorhandener Übertragungsmodi als Beispiele folgen, aber dieser Modus ist auch etwas selten und ich hatte keine Geräte, auf denen ich ihn testen konnte, daher habe ich ihn vorerst als nicht unterstützt belassen. Wenn Sie solche Geräte haben und einen Beitrag zur Bibliothek leisten möchten, sind PRs herzlich willkommen!
  • Wie bereits erwähnt, sind plattformübergreifende Einschränkungen erwähnt. Diese Einschränkungen unterliegen den Betriebssystemen, daher können wir hier nicht viel tun, außer dass Nutzer aufgefordert werden, 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önnen beispielsweise eine C-Bibliothek hidapi zu WebHID portieren und diese Probleme, die mit dem Low-Level-USB-Zugriff verbunden sind, umgehen.

Fazit

In diesem Beitrag habe ich gezeigt, wie mit Emscripten, Asyncify und Fugu APIs sogar Low-Level-Bibliotheken wie libusb mit ein paar Tricks ins Web übertragen werden können.

Die Portierung solch wichtigen und weit verbreiteten Low-Level-Bibliotheken ist besonders lohnend, da sie es wiederum ermöglicht, übergeordnete Bibliotheken oder sogar ganze Anwendungen ins Web zu bringen. Dadurch werden Funktionen geöffnet, die bisher auf Nutzer von ein oder zwei Plattformen und für alle Arten von Geräten und Betriebssystemen beschränkt waren. Diese Funktionen sind nur einen Klick entfernt.

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