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, mit der File System Access API, WebAssembly und Asyncify ins Web übertragen werden. Nun möchte ich weiter mit der Integration von Fugu APIs in WebAssembly und der Portierung von Apps in das 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 Sie libusb – eine beliebte USB-Bibliothek in C – in WebAssembly (über Emscripten), Asyncify und WebUSB übertragen.

Das Wichtigste zuerst: eine Demo

Das Wichtigste bei der Übertragung einer Bibliothek ist die Auswahl der richtigen Demo. Sie sollte die Funktionen der portierten Bibliothek demonstrieren, die Sie auf verschiedene Arten testen und gleichzeitig optisch ansprechend sein können.

Meine Idee war die Fernbedienung für Spiegelreflexkameras. Besonders das Open-Source-Projekt gPhoto2 gibt es schon lange genug, um eine Vielzahl von Digitalkameras zurückzuentwickeln und die Unterstützung dafür zu implementieren. Es unterstützt mehrere Protokolle, aber am meisten interessierte ich mich für die USB-Unterstützung, die über Libusb ausgeführt wird.

Ich werde die Schritte zum Erstellen dieser Demo in zwei Teilen beschreiben. In diesem Blogpost werde ich beschreiben, wie ich libusb selbst portiert habe und welche Tricks erforderlich sind, um andere beliebte Bibliotheken in Fugu-APIs zu portieren. Im zweiten Beitrag gehe ich ausführlich auf die Portierung und Integration von gPhoto2 selbst ein.

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

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Die Demo wird auf einem Laptop ausgeführt, der mit einer Sony-Kamera verbunden ist.

Hinweis zu kameraspezifischen Macken

Wie du vielleicht schon bemerkt hast, dauert das Ändern der Einstellungen im Video eine Weile. Wie bei den meisten anderen Problemen ist dies nicht auf die Leistung von WebAssembly oder WebUSB zurückzuführen, sondern auf die Art und Weise, wie gPhoto2 mit der für die Demo ausgewählten Kamera interagiert.

Sony A6600 verfügt nicht über eine API, um Werte wie ISO, Blende oder Belichtungszeit direkt einzustellen, sondern bietet nur Befehle zum Erhöhen oder Verringern um die angegebene Anzahl von Schritten. Der Einfachheit halber wird 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. Warte etwas, bis die Kamera die Einstellungen aktualisiert hat.
  3. Lesen Sie den Wert zurück, auf dem die Kamera tatsächlich gelandet ist.
  4. Achten Sie darauf, dass der letzte Schritt nicht über den gewünschten Wert hinausgesprungen ist und nicht das Ende oder den Anfang der Liste umschlossen hat.
  5. Wiederholen.

Es kann einige Zeit dauern, aber wenn der Wert tatsächlich von der Kamera unterstützt wird, kommt er dorthin. Andernfalls wird er beim nächsten unterstützten Wert beendet.

Andere Kameras haben wahrscheinlich andere Einstellungen, zugrunde liegende APIs und Eigenheiten. Denken Sie daran, dass gPhoto2 ein Open-Source-Projekt ist und entweder automatisierte oder manuelle Tests aller da draußen verfügbaren Kameramodelle einfach nicht realisierbar sind. Daher sind detaillierte Problemberichte und PRs immer willkommen (aber stellen Sie sicher, dass Sie die Probleme zuerst mit dem offiziellen gPhoto2-Kunden reproduzieren).

Wichtige Hinweise zur plattformübergreifenden Kompatibilität

Leider sind unter Windows alle "bekannten" Geräten, einschließlich DSLR-Kameras, wird 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 angeschlossene DSLR-Kamera entweder über WinUSB oder libusb zu überschreiben. Dieser Ansatz funktioniert zwar sowohl für mich als auch für viele andere Nutzer, aber Sie sollten ihn auf eigenes Risiko verwenden.

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

Unter macOS und Android sollte die Demo sofort funktionieren. Wenn du es auf einem Android-Smartphone ausprobierst, stelle sicher, dass du in das Querformat umschaltest, da ich nicht viel Mühe gegeben habe, es responsiv zu gestalten (PRs sind willkommen!):

<ph type="x-smartling-placeholder">
</ph> Android-Smartphone, das über ein USB-C-Kabel mit einer Canon-Kamera verbunden ist <ph type="x-smartling-placeholder">
</ph> Dieselbe Demo wird auf einem Android-Smartphone ausgeführt. Bild von Surma.

Einen ausführlicheren Leitfaden zur plattformübergreifenden Nutzung von WebUSB finden Sie unter „Plattformspezifische Überlegungen“ im Artikel "Geräte für WebUSB erstellen" beschrieben.

Neues Back-End zu libusb hinzufügen

Nun zu den technischen Details. Es ist zwar möglich, eine Shim-API ähnlich wie libusb, die bereits von anderen verwendet wurde, bereitzustellen und andere Anwendungen damit zu verknüpfen. Dieser Ansatz ist jedoch fehleranfällig und erschwert jede weitere Erweiterung oder Wartung. Ich wollte Dinge richtig machen, die potenziell wieder vorgelagert und in Zukunft in Libusb zusammengeführt werden könnten.

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

„libusb wird intern so abstrahiert, dass es hoffentlich auf 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 Back-Ends sind dafür verantwortlich, die Geräte über die Low-Level-APIs des Betriebssystems aufzulisten, zu öffnen, zu schließen und tatsächlich mit den Geräten zu kommunizieren. libusb abstrahiert bereits die Unterschiede zwischen Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku und Solaris und funktioniert auf all diesen Plattformen.

Ich musste lediglich ein weiteres Backend 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 Header libusbi.h mit gängigen Typen und Hilfsprogrammen 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 uns die Eigenschaften ansehen, stellen wir fest, dass die Struktur den Back-End-Namen, eine Reihe von Funktionen, Handler für verschiedene Low-Level-USB-Vorgänge in Form von Funktionszeigern und schließlich Größen enthält, die zum Speichern privater Geräte-/Kontext-/Übertragungsdaten zugewiesen werden sollen.

Die privaten Datenfelder sind zumindest nützlich, um Betriebssystem-Handles für all diese Dinge zu speichern, da wir ohne Aliasse nicht wissen, für welches Element ein bestimmter Vorgang gilt. In der Webimplementierung wären die Betriebssystem-Handles die zugrunde liegenden WebUSB-JavaScript-Objekte. In Emscripten werden sie normalerweise über die Klasse emscripten::val dargestellt und gespeichert, die als Teil von Embind, dem Bindungssystem von Emscripten, bereitgestellt wird.

Die meisten Back-Ends im Ordner sind in C implementiert, einige jedoch auch 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 Felder für private Daten 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 gebrauchsfertige Verweise auf den zugewiesenen Bereich für private Daten bereit. Damit ich mit diesen Zeigern als val-Instanzen arbeiten kann, habe ich kleine Hilfsprogramme hinzugefügt, die sie direkt erstellen, als Referenzen abrufen und Werte heraus 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

Es war nun eine Möglichkeit erforderlich, asynchrone WebUSB APIs zu verarbeiten, bei denen libusb synchrone Vorgänge erwartet. Dafür kann ich Asyncify oder insbesondere die Embind-Integration über val::await() verwenden.

Ich wollte auch WebUSB-Fehler korrekt verarbeiten und sie in Libusb-Fehlercodes konvertieren, aber Embind hat derzeit keine Möglichkeit, JavaScript-Ausnahmen oder Promise-Ablehnungen von 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 nun sicher von der C++-Seite geparst werden kann. Dazu habe ich eine Kombination aus dem EM_JS-Makro und den Emval.to{Handle, Value} APIs verwendet:

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 jedes Promise verwenden, das von WebUSB-Vorgängen zurückgegeben wird, und die Felder error und value separat untersuchen.

Beispiel: Das Abrufen einer val, die ein USBDevice darstellt, von libusb_device_handle, das Aufrufen der zugehörigen open()-Methode, das Warten auf das Ergebnis und die Rückgabe eines Fehlercodes als Libusb-Statuscode sieht 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, anders als auf anderen Plattformen, aus Sicherheitsgründen keine Möglichkeit gibt, alle verbundenen USB-Geräte im Web aufzuführen. Stattdessen ist der Ablauf in zwei Teile gegliedert. Zuerst fordert die Webanwendung über navigator.usb.requestDevice() Geräte mit bestimmten Eigenschaften an. Der Nutzer wählt dann manuell aus, auf welchem Gerät die Berechtigungsaufforderung angezeigt werden soll, oder lehnt die Aufforderung ab. Danach werden 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 vertraulich und muss durch eine Nutzerinteraktion ausgelöst werden, z. B. durch das Klicken auf eine Schaltfläche. Andernfalls wird immer ein abgelehntes Promise zurückgegeben. libusb-Anwendungen werden oft die verbundenen Geräte beim Start der Anwendung auflisten, daher war die Verwendung von requestDevice() keine Option.

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

// 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 oben gezeigt. Es gibt einige weitere interessante Hacks im Code zur Verarbeitung der Datenübertragung, aber diese Implementierungsdetails sind für die Zwecke dieses Artikels weniger wichtig. Sehen Sie sich den Code und die Kommentare auf GitHub an, wenn Sie interessiert sind.

Portierung von Ereignisschleifen in das Web

Ein weiterer Teil des Libusb-Ports, über den ich sprechen möchte, ist die Ereignisverarbeitung. Wie im vorherigen Artikel beschrieben, sind die meisten APIs in Systemsprachen wie C synchron und die Ereignisverarbeitung ist keine Ausnahme. Sie wird normalerweise über eine Endlosschleife implementiert, die „Umfragen“ durchführt, (versucht, Daten zu lesen, oder blockiert die Ausführung, bis Daten verfügbar sind) aus einer Gruppe externer E/A-Quellen. Wenn mindestens eine dieser Quellen antwortet, übergibt sie dieses als Ereignis an den entsprechenden Handler. Sobald der Handler fertig ist, kehrt das Steuerelement zur Schleife zurück und unterbricht eine weitere Abfrage.

Im Web gibt es bei diesem Ansatz einige Probleme.

Erstens kann WebUSB die unverarbeiteten Handles der zugrunde liegenden Geräte nicht offenlegen und auch nicht veröffentlichen. Daher ist das direkte Abrufen dieser Aliasse nicht möglich. Zweitens verwendet libusb die APIs eventfd und pipe für andere Ereignisse sowie für Übertragungen auf Betriebssystemen ohne unformatierte Geräte-Aliasse. eventfd wird derzeit in Emscripten nicht unterstützt. pipe wird zwar unterstützt, entspricht derzeit jedoch nicht der Spezifikation und kann nicht auf Ereignisse warten.

Schließlich besteht das größte Problem darin, dass das Web eine eigene Ereignisschleife hat. Diese globale Ereignisschleife wird für alle externen E/A-Vorgänge verwendet (einschließlich fetch(), Timer oder in diesem Fall WebUSB) und ruft Ereignis- oder Promise-Handler auf, sobald die entsprechenden Vorgänge abgeschlossen sind. Die Ausführung einer weiteren verschachtelten Endlosschleife verhindert den Fortschritt der Ereignisschleife des Browsers. Das bedeutet, dass nicht nur die Benutzeroberfläche nicht mehr reagiert, sondern auch, dass der Code niemals Benachrichtigungen für die E/A-Ereignisse erhält, auf die er wartet. Dies führt in der Regel zu einem Deadlock, und das geschah auch, 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 eine Möglichkeit finden, diese Schleifen auszuführen, ohne den Hauptthread zu blockieren, um solche Ereignisschleifen ins Web zu übertragen. 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, Asyncify zu verwenden, um die Schleife anzuhalten und auf nicht blockierende Ereignisse auf Ereignisse zu warten.

Ich wollte weder libusb noch gPhoto2 wesentlich ändern und habe Asyncify bereits für die Promise-Integration verwendet, also habe ich diesen Weg gewählt. 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

Das Tool funktioniert:

  1. Ruft poll() auf, um zu prüfen, ob vom Back-End noch Ereignisse gemeldet wurden. Falls es welche 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 verwendet im Hintergrund Asyncify und setTimeout() und wird hier genutzt, um die Steuerung der Hauptereignisschleife des Browsers zu ermöglichen. Dadurch kann der Browser alle Nutzerinteraktionen und E/A-Ereignisse verarbeiten, einschließlich WebUSB.
  3. Prüfen Sie, ob das angegebene Zeitlimit bereits abgelaufen ist, und setzen Sie andernfalls die Schleife fort.

Wie im Kommentar erwähnt wird, war dieser Ansatz nicht optimal, da der gesamte Aufrufstack mit Asyncify wiederhergestellt werden konnte, auch wenn noch keine USB-Ereignisse verarbeitet wurden (die meiste Zeit) und weil setTimeout() in modernen Browsern eine minimale Dauer von 4 ms hat. Trotzdem hat es gut funktioniert, um einen Livestream mit 13–14 fps mit digitalen Spiegelreflexkameras als Proof of Concept zu erstellen.

Später beschloss ich, es durch den Einsatz des Browser-Ereignissystems zu verbessern. Es gibt mehrere Möglichkeiten, wie diese Implementierung weiter verbessert werden könnte, aber jetzt habe ich beschlossen, benutzerdefinierte Ereignisse direkt für das globale Objekt auszugeben, ohne sie mit einer bestimmten Libusb-Datenstruktur zu verknüpfen. Ich habe dazu den folgenden Mechanismus verwendet, 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 wie den Abschluss einer Datenübertragung zu melden:

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
}

In der Zwischenzeit wird der Teil em_libusb_wait() zum „Aufwachen“ verwendet aus dem Asyncify-Ruhemodus, wenn 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 Wakeups wurde durch diesen Mechanismus die Effizienz der früheren emscripten_sleep()-basierten Implementierung behoben und der Durchsatz der DSLR-Demo von 13–14 fps auf konstant 30 fps erhöht, was für einen reibungslosen Livefeed ausreicht.

System erstellen und erster Test

Nachdem das Backend fertig war, musste ich es Makefile.am und configure.ac hinzufügen. Interessant ist hier nur 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 unterschiedliche Ausgaben, je nachdem, welche Erweiterung Sie anfordern. Ich verwende AC_SUBST(EXEEXT, …), um die ausführbare Erweiterung in .html zu ändern, sodass jede ausführbare Datei innerhalb eines Pakets – Tests und Beispiele – zu einem HTML-Code mit der Standard-Shell von Emscripten wird, die JavaScript und WebAssembly lädt und instanziiert.

Zweitens: Da ich Embind und Asyncify verwende, muss ich diese Funktionen aktivieren (--bind -s ASYNCIFY) und dynamisches Arbeitsspeicherwachstum (-s ALLOW_MEMORY_GROWTH) über Verknüpfungsparameter zulassen. Leider gibt es keine Möglichkeit für eine Bibliothek, diese Flags an die Verknüpfung zu melden. Daher muss jede Anwendung, die diesen Libusb-Port verwendet, die gleichen Verknüpfungs-Flags auch ihrer Build-Konfiguration hinzufügen.

Wie bereits erwähnt, muss die Geräteaufzählung für WebUSB über eine Nutzergeste erfolgen. libusb-Beispiele und -Tests setzen voraus, dass Geräte beim Start aufgelistet werden können, und schlagen ohne Änderungen mit einem Fehler fehl. Stattdessen musste ich die automatische Ausführung deaktivieren (-s INVOKE_RUN=0) und die manuelle callMain()-Methode (-s EXPORTED_RUNTIME_METHODS=...) verfügbar machen.

Nachdem das alles erledigt war, 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 mit geöffneten Entwicklertools auf einer lokal bereitgestellten „testlibusb“-Seite. In der Entwicklertools-Konsole wird „navigator.usb.requestDevice({ filters: [] })“ ausgewertet. Dadurch wird eine Berechtigungsaufforderung ausgelöst und der Nutzer aufgefordert, ein USB-Gerät auszuwählen, das für die Seite freigegeben werden soll. Derzeit ist ILCE-6600 (eine Sony-Kamera) ausgewählt.

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

Es sieht nicht nach viel aus, aber bei der Portierung von Bibliotheken auf eine neue Plattform ist es ziemlich spannend, zum ersten Mal eine gültige Ausgabe zu generieren.

Port verwenden

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

  1. Laden Sie die aktuelle Version von libusb herunter. Sie können sie entweder als Archiv als Teil Ihres Builds herunterladen oder Ihrem Projekt als Git-Submodul hinzufügen.
  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. Suchen Sie in der Anwendung oder Bibliothek auf höherer Ebene unter dem zuvor ausgewählten Pfad nach der Libusb-Datei.
  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:

  • Keine Unterstützung bei Stornierung von Übertragungen. Dies ist eine Einschränkung von WebUSB, die wiederum darauf zurückgeht, dass die plattformübergreifende Übertragung in LIbusb selbst nicht abgebrochen wird.
  • Keine isochronische Übertragung. Es ist nicht schwer, ihn anhand der Implementierung vorhandener Übertragungsmodi als Beispiele hinzuzufügen, aber dieser Modus ist auch etwas selten und ich hatte keine Geräte, auf denen ich ihn testen konnte, also habe ich ihn vorerst als nicht unterstützt belassen. Wenn du solche Geräte besitzt und zur Bibliothek beitragen möchtest, sind PRs herzlich willkommen!
  • Die zuvor erwähnten plattformübergreifenden Einschränkungen. Diese Einschränkungen werden durch das Betriebssystem auferlegt. Daher können wir hier nicht viel tun, außer Nutzer bitten, den Treiber oder die Berechtigungen zu überschreiben. Wenn Sie jedoch HID- oder serielle Geräte mitnehmen, können Sie dem Libusb-Beispiel folgen und eine andere Bibliothek in eine andere Fugu API übertragen. Sie könnten beispielsweise eine C-Bibliothek hidapi zu WebHID portieren und die mit dem Low-Level-USB-Zugriff verbundenen Probleme vollständig umgehen.

Fazit

In diesem Beitrag habe ich gezeigt, wie mithilfe der Emscripten-, Asyncify- und Fugu-APIs selbst Low-Level-Bibliotheken wie libusb mit einigen Integrationstricks ins Web übertragen werden können.

Die Portierung so wichtiger und weit verbreiteter Low-Level-Bibliotheken ist besonders lohnenswert, da dadurch auch übergeordnete Bibliotheken oder sogar ganze Anwendungen ins Web gebracht werden können. Dadurch werden Funktionen geöffnet, die zuvor auf Nutzer von ein oder zwei Plattformen und allen Arten von Geräten und Betriebssystemen beschränkt waren. Diese Funktionen sind nur einen Linkklick entfernt.

Im nächsten Beitrag werde ich die Schritte zur Erstellung der gPhoto2-Webdemo erläutern, bei der nicht nur Geräteinformationen abgerufen, sondern auch die Übertragungsfunktion von Libusb intensiv genutzt wird. In der Zwischenzeit hoffe ich, dass Sie das Libusb-Beispiel inspirieren konnten. Sie werden die Demo ausprobieren, mit der Bibliothek selbst experimentieren oder vielleicht sogar eine andere weit verbreitete Bibliothek in eine der Fugu-APIs übertragen.