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 früheren Beitrag habe ich gezeigt, wie Sie Apps mit Dateisystem-APIs mithilfe der File System Access API, WebAssembly und Asyncify ins Web portieren. Ich möchte jetzt mit demselben Thema fortfahren: der Integration von Fugu APIs in WebAssembly und dem Portieren von Apps ins Web, ohne wichtige Funktionen zu verlieren.

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

Das Wichtigste zuerst: Eine Demo

Das Wichtigste beim Portieren einer Bibliothek ist die Auswahl der richtigen Demo. Diese sollte die Funktionen der portierten Bibliothek demonstrieren, sie auf unterschiedliche Weise testen lassen und gleichzeitig visuell ansprechend sein.

Ich habe mich für die Fernbedienung einer DSLR entschieden. Insbesondere das Open-Source-Projekt gPhoto2 ist schon lange in diesem Bereich aktiv und hat die Unterstützung für eine Vielzahl von Digitalkameras durch Reverse-Engineering implementiert. Es unterstützt mehrere Protokolle, aber das, das mich am meisten interessierte, war 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 möglicherweise erforderlich sind, um andere gängige Bibliotheken zu Fugu APIs zu portieren. Im zweiten Beitrag gehe ich ausführlich auf die Portierung und Integration von gPhoto2 selbst ein.

Am Ende hatte ich eine funktionierende Webanwendung, die einen Livefeed von einer DSLR in der Vorschau anzeigt und deren Einstellungen über USB steuern kann. Sehen Sie sich die Live-Demo oder die aufgezeichnete Demo an, bevor Sie sich mit den technischen Details vertraut machen:

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

Hinweis zu kameraspezifischen Besonderheiten

Sie haben vielleicht bemerkt, dass das Ändern der Einstellungen im Video etwas dauert. Wie bei den meisten anderen Problemen, die Sie möglicherweise sehen, ist dies nicht auf die Leistung von WebAssembly oder WebUSB zurückzuführen, sondern darauf, wie gPhoto2 mit der für die Demo ausgewählten Kamera interagiert.

Die Sony a6600 bietet keine API, um Werte wie ISO, Blende oder Verschlusszeit direkt festzulegen. Stattdessen gibt es nur Befehle, um sie um die angegebene Anzahl von Schritten zu erhöhen oder zu verringern. Erschwerend kommt hinzu, dass auch keine Liste der tatsächlich unterstützten Werte zurückgegeben wird. Die 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 oder mehrere Schritte in Richtung des ausgewählten Werts.
  2. Warten Sie, bis die Kamera die Einstellungen aktualisiert hat.
  3. Lesen Sie den Wert ab, den die Kamera tatsächlich erfasst hat.
  4. Prüfen Sie, ob der gewünschte Wert nicht übersprungen wurde und ob die Liste nicht am Ende oder Anfang der Liste angekommen ist.
  5. Wiederholen.

Das kann einige Zeit dauern, aber wenn der Wert von der Kamera unterstützt wird, wird er erreicht. Andernfalls wird der nächstniedrigere unterstützte Wert verwendet.

Andere Kameras haben wahrscheinlich unterschiedliche Einstellungen, zugrunde liegende APIs und Besonderheiten. Denken Sie daran, dass gPhoto2 ein Open-Source-Projekt ist und es einfach nicht möglich ist, alle Kameramodelle automatisch oder manuell zu testen. Aus diesem Grund 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 mit einem Tool wie Zadig den Treiber für die angeschlossene DSLR entweder auf WinUSB oder libusb umstellen. Dieser Ansatz funktioniert bei mir und vielen anderen Nutzern gut, aber Sie sollten ihn auf eigenes Risiko 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 ohne weitere Maßnahmen funktionieren. Wenn Sie es auf einem Android-Smartphone ausprobieren, wechseln Sie in den Querformatmodus, da ich nicht viel Aufwand in die Responsivität gesteckt habe (PRs sind willkommen):

Ein Android-Smartphone, das über ein USB-C-Kabel mit einer Canon-Kamera verbunden ist.
Die gleiche 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 „Gerät für WebUSB erstellen“.

Hinzufügen eines neuen ‑Backends zu libusb

Kommen wir nun zu den technischen Details. Es ist zwar möglich, eine ähnliche Shim-API wie libusb bereitzustellen (was bereits von anderen getan wurde) und andere Anwendungen damit zu verknüpfen, aber dieser Ansatz ist fehleranfällig und erschwert weitere Erweiterungen oder Wartungen. Ich wollte alles richtig machen, so dass es in Zukunft in die Upstream-Entwicklung einfließen und in libusb zusammengeführt werden kann.

Glücklicherweise steht in der libusb-README 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 den „Backends“ getrennt ist. Diese Backends 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 allen 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 Back-End enthält den Header libusbi.h mit gängigen Typen und Hilfsfunktionen und muss eine usbi_backend-Variable vom Typ usbi_os_backend bereitstellen. Das Windows-Backend sieht beispielsweise so 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, sehen wir, dass die Struktur den Namen des Backends, eine Reihe seiner Funktionen, Handler für verschiedene Low-Level-USB-Vorgänge in Form von Funktionszeigern und schließlich Größen zum Speichern privater Daten auf Geräte-, Kontext- und Übertragungsebene enthält.

Die privaten Datenfelder sind zumindest zum Speichern von OS-Handles für all diese Elemente nützlich, da wir ohne Handles nicht wissen, auf welches Element sich ein bestimmter Vorgang bezieht. Bei der Webimplementierung sind die Betriebssystem-Handle die zugrunde liegenden WebUSB-JavaScript-Objekte. Die natürliche Darstellung und Speicherung in Emscripten erfolgt über die Klasse emscripten::val, die im Rahmen von Embind (das Bindungssystem von Emscripten) bereitgestellt wird.

Die meisten Backends im Ordner sind in C implementiert, einige aber in C++. Embind funktioniert nur mit C++, daher war die Entscheidung für mich getroffen. Ich habe libusb/libusb/os/emscripten_webusb.cpp mit der erforderlichen Struktur und 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-Handle speichern

libusb stellt gebrauchsfertige Verweise auf den zugewiesenen Bereich für private Daten bereit. Damit ich mit diesen Pointern als val-Instanzen arbeiten kann, habe ich kleine Hilfsfunktionen hinzugefügt, die sie vor Ort erstellen, als Referenzen abrufen und Werte herauskopieren:

// 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 wurde nun eine Möglichkeit benötigt, asynchrone WebUSB APIs zu verarbeiten, bei denen libusb synchrone Vorgänge erwartet. Dazu könnte ich Asyncify oder genauer gesagt die Embind-Integration über val::await() verwenden.

Außerdem wollte ich WebUSB-Fehler richtig behandeln und in libusb-Fehlercodes konvertieren. Mit Embind gibt es derzeit jedoch keine Möglichkeit, JavaScript-Ausnahmen oder Promise-Ablehnungen von der C++-Seite aus zu verarbeiten. Dieses Problem lässt sich umgehen, indem eine Ablehnung auf der JavaScript-Seite abgefangen und das Ergebnis in ein { error, value }-Objekt umgewandelt wird, das jetzt sicher von der C++-Seite aus geparst werden kann. Dazu habe ich das EM_JS-Makro mit den 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() auf alle Promise anwenden, die von WebUSB-Vorgängen zurückgegeben werden, und die Felder error und value separat prüfen.

Das Abrufen eines val, das ein USBDevice von libusb_device_handle darstellt, das Aufrufen der Methode open(), das Warten auf das Ergebnis und das Zurückgeben eines Fehlercodes als libusb-Statuscode sieht beispielsweise 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.

Das Problem ist, 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 Fluss 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 Einblenden einer Berechtigungsanfrage mit einer Liste der verbundenen Geräte gilt jedoch als sensibler Vorgang und muss durch eine Nutzerinteraktion ausgelöst werden (z. B. durch Klicken auf eine Schaltfläche auf einer Seite). Andernfalls wird immer ein abgelehntes Promise zurückgegeben. Da libusb-Anwendungen die verbundenen Geräte häufig beim Start der Anwendung auflisten möchten, war die Verwendung von requestDevice() keine Option.

Stattdessen musste ich die Aufrufe 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;
}

Im Großteil des Backend-Codes werden val und promise_result auf ähnliche Weise verwendet, wie oben bereits gezeigt. Im Code zur Datenverarbeitung gibt es noch einige weitere interessante Hacks, aber diese Implementierungsdetails sind für diesen Artikel weniger wichtig. Sehen Sie sich den Code und die Kommentare auf GitHub an, wenn Sie daran interessiert sind.

Ereignisschleifen ins Web portieren

Ein weiterer Teil des libusb-Ports, den ich erwähnen möchte, ist die Ereignisbehandlung. Wie im vorherigen Artikel beschrieben, sind die meisten APIs in Systemsprachen wie C synchron. Die Ereignisbehandlung ist keine Ausnahme. Sie wird in der Regel über einen unendlichen Loop implementiert, der von einer Reihe externer E/A-Quellen „pollt“ (d. h. versucht, Daten zu lesen oder die Ausführung blockiert, bis Daten verfügbar sind) und, wenn mindestens eine davon antwortet, diese als Ereignis an den entsprechenden Handler weitergibt. Sobald der Handler fertig ist, kehrt die Steuerung zur Schleife zurück und pausiert für eine weitere Abfrage.

Bei diesem Ansatz gibt es jedoch einige Probleme im Web.

Erstens: WebUSB stellt keine Roh-Handle der zugrunde liegenden Geräte bereit und kann dies auch nicht. Daher ist ein direkter Abruf nicht möglich. Zweitens verwendet libusb die APIs eventfd und pipe für andere Ereignisse sowie für die Verarbeitung von Übertragungen auf Betriebssystemen ohne Raw-Geräte-Handle. eventfd wird derzeit jedoch nicht in Emscripten unterstützt und pipe, obwohl unterstützt, entspricht derzeit nicht der Spezifikation 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 (einschließlich fetch(), Timer oder in diesem Fall WebUSB) verwendet und ruft Ereignis- oder Promise-Handler auf, sobald die entsprechenden Vorgänge abgeschlossen sind. Wenn Sie eine weitere verschachtelte, endlose Ereignisschleife ausführen, wird die Ereignisschleife des Browsers blockiert, sodass nicht nur die Benutzeroberfläche nicht mehr reagiert, sondern der Code auch nie Benachrichtigungen für die I/O-Ereignisse erhält, auf die er wartet. Das führt in der Regel zu einem Deadlock. Das war auch der Fall, als ich versuchte, 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, solche Ereignisschleifen auszuführen, ohne den Hauptthread zu blockieren, um sie ins Web zu portieren. Eine Möglichkeit besteht darin, die Anwendung so zu refaktorisieren, 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 zu pausieren und nicht blockierend auf Ereignisse zu warten.

Ich wollte weder an libusb noch an gPhoto2 größere Änderungen vornehmen und da ich Asyncify bereits für die Promise-Integration verwendet habe, 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 wie unten gezeigt verwendet:

#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

Dabei geschieht Folgendes:

  1. Ruft poll() auf, um zu prüfen, ob vom Backend bereits Ereignisse gemeldet wurden. Falls ja, wird die Schleife beendet. Andernfalls gibt die Emscripten-Implementierung von poll() sofort 0 zurück.
  2. Ruft emscripten_sleep(0) auf. Diese Funktion verwendet Asyncify und setTimeout() und wird hier verwendet, um die Kontrolle an den Hauptereignis-Loop des Browsers zurückzugeben. So kann der Browser alle Nutzerinteraktionen und I/O-Ereignisse verarbeiten, einschließlich WebUSB.
  3. Prüfen Sie, ob die angegebene Zeitüberschreitung bereits abgelaufen ist. Falls nicht, fahren Sie mit der Schleife fort.

Wie im Kommentar erwähnt, war dieser Ansatz nicht optimal, da der gesamte Aufrufstapel mit Asyncify gespeichert und wiederhergestellt wurde, auch wenn noch keine USB-Ereignisse zu verarbeiten waren (was die meiste Zeit der Fall ist). Außerdem hat setTimeout() selbst in modernen Browsern eine minimale Dauer von 4 ms. Trotzdem funktionierte es gut genug, um im Proof of Concept einen Livestream mit 13–14 fps von der DSLR zu produzieren.

Später habe ich beschlossen, sie zu verbessern, indem ich das Browserereignissystem nutze. Diese Implementierung könnte auf verschiedene Arten weiter verbessert werden. Ich habe mich jedoch entschieden, benutzerdefinierte Ereignisse direkt über das globale Objekt zu senden, ohne sie mit einer bestimmten libusb-Datenstruktur zu verknüpfen. Dazu habe ich den folgenden Mechanismus zum Warten und Benachrichtigen 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 verwendet, wenn libusb versucht, ein Ereignis zu melden, z. B. den Abschluss der 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 Teil em_libusb_wait() wird verwendet, um den Asyncify-Ruhemodus zu beenden, wenn entweder ein em-libusb-Ereignis empfangen wird oder die Zeitüberschreitung 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 Aufwachvorgängen wurden die Effizienzprobleme der früheren emscripten_sleep()-basierten Implementierung behoben und der Durchsatz der DSLR-Demo von 13–14 fps auf über 30 fps erhöht, was für einen flüssigen Livefeed ausreicht.

System erstellen und ersten Test durchführen

Nachdem das Backend fertig war, musste ich es Makefile.am und configure.ac hinzufügen. Das einzige Interessante 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: Ausführbare Dateien auf Unix-Plattformen haben normalerweise keine Dateiendungen. Emscripten liefert jedoch je nach angeforderter Erweiterung eine andere Ausgabe. Ich verwende AC_SUBST(EXEEXT, …), um die Erweiterung der ausführbaren Datei in .html zu ändern, damit 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: Da ich Embind und Asyncify verwende, muss ich diese Funktionen (--bind -s ASYNCIFY) aktivieren und über Linkparameter ein dynamisches Speicherwachstum (-s ALLOW_MEMORY_GROWTH) zulassen. Leider gibt es keine Möglichkeit, dass eine Bibliothek diese Flags an den Linker meldet. Daher müssen alle Anwendungen, die diesen libusb-Port verwenden, dieselben Linker-Flags auch in ihre Build-Konfiguration aufnehmen.

Wie bereits erwähnt, muss bei WebUSB die Geräteaufzählung über eine Nutzergeste erfolgen. In den Beispielen und Tests von libusb wird davon ausgegangen, dass Geräte beim Start aufgezählt werden können, und es kommt ohne Änderungen zu einem Fehler. Stattdessen musste ich die automatische Ausführung (-s INVOKE_RUN=0) deaktivieren und die manuelle callMain()-Methode (-s EXPORTED_RUNTIME_METHODS=...) freigeben.

Danach konnte ich die generierten Dateien mit einem statischen Webserver bereitstellen, WebUSB initialisieren und diese HTML-Ausführprogramme mithilfe der DevTools manuell ausführen.

Screenshot eines Chrome-Fensters mit geöffneten Entwicklertools auf einer lokal bereitgestellten Seite „testlibusb“. In der DevTools-Konsole wird „navigator.usb.requestDevice({ filters: [] })“ ausgewertet. Dadurch wurde eine Berechtigungsanfrage ausgelöst und der Nutzer wird derzeit 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, die Entwicklertools sind noch geöffnet. Nachdem das Gerät ausgewählt wurde, hat die Console den neuen Ausdruck „Module.callMain([&#39;-v&#39;])“ ausgewertet, wodurch die App „testlibusb“ im ausführlichen Modus ausgeführt wurde. Die Ausgabe enthält verschiedene detaillierte Informationen zur zuvor verbundenen USB-Kamera: Hersteller Sony, Produkt ILCE-6600, Seriennummer, Konfiguration usw.

Es sieht nicht nach viel aus, aber beim Portieren von Bibliotheken auf eine neue Plattform ist es ziemlich aufregend, wenn zum ersten Mal eine gültige Ausgabe ausgegeben wird.

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, gehen Sie so vor:

  1. Laden Sie die neueste libusb entweder als Archiv im Rahmen Ihres Builds herunter oder fügen Sie sie als Git-Submodul in Ihr 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 plattformübergreifende Kompilierung zu initialisieren und einen Pfad festzulegen, unter dem die erstellten Artefakte abgelegt werden sollen.
  4. Führen Sie emmake make install aus.
  5. Weisen Sie Ihre Anwendung oder Bibliothek auf höherer Ebene an, nach der libusb unter dem zuvor ausgewählten Pfad zu suchen.
  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:

  • Es wird keine Unterstützung für die Stornierung von Übertragungen angeboten. 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 Übertragungen. Es sollte nicht schwierig sein, ihn hinzuzufügen, indem man die Implementierung vorhandener Übertragungsmodi als Beispiel verwendet. Es ist jedoch auch ein etwas seltener Modus und ich hatte keine Geräte, auf denen ich ihn testen konnte. Daher habe ich ihn vorerst nicht unterstützt. Wenn Sie solche Geräte haben und zur Bibliothek beitragen möchten, sind PRs willkommen.
  • Die oben genannten 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 Beispiel für libusb folgen und eine andere Bibliothek auf eine andere Fugu API portieren. Sie können beispielsweise eine C-Bibliothek hidapi auf WebHID portieren und diese Probleme, die mit dem Low-Level-USB-Zugriff verbunden sind, vollständig umgehen.

Fazit

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

Das Portieren solcher wichtigen und weit verbreiteten Low-Level-Bibliotheken ist besonders lohnenswert, da dadurch auch Bibliotheken höherer Ebene oder sogar ganze Anwendungen ins Web gebracht werden können. So können Funktionen, die zuvor nur Nutzern einer oder zwei Plattformen zur Verfügung standen, jetzt auf allen Arten von Geräten und Betriebssystemen genutzt werden.

Im nächsten Beitrag erkläre ich, wie Sie die gPhoto2-Webdemo erstellen, die nicht nur Geräteinformationen abrufen, sondern auch die Übertragungsfunktion von libusb intensiv nutzt. In der Zwischenzeit hoffe ich, dass Sie das libusb-Beispiel inspirierend fanden und die Demo ausprobieren, mit der Bibliothek selbst herumspielen oder vielleicht sogar eine andere weit verbreitete Bibliothek auf eine der Fugu APIs portieren.