Przenoszenie aplikacji USB do internetu. Część 1. libusb

Dowiedz się, jak kod współpracujący z urządzeniami zewnętrznymi można przenieść do internetu przy użyciu interfejsów WebAssembly i Fugu API.

W poprzednim poście pokazałem, jak przenosić aplikacje korzystające z interfejsów API systemu plików do internetu za pomocą File System Access API, WebAssembly i Asyncify. Teraz chciałabym kontynuować integrację interfejsów Fugu API z WebAssembly oraz przenoszenie aplikacji do internetu bez utraty ważnych funkcji.

Pokażę, jak aplikacje komunikujące się z urządzeniami USB można przenieść do internetu przez przeniesienie biblioteki libusb – popularnej biblioteki USB napisanej w języku C – do narzędzi WebAssembly (za pomocą Emscripten), Asyncify i WebUSB.

Na początek demo

Najważniejszym elementem portowania biblioteki jest wybranie odpowiedniej wersji demonstracyjnej – takiej, która pokazuje możliwości przeniesionej biblioteki, pozwala przetestować ją na różne sposoby i jest jednocześnie atrakcyjna wizualnie.

Wybrałem pomysł polegający na sterowaniu lustrzanką za pomocą pilota. W szczególności projekt open source gPhoto2 istnieje już od dłuższego czasu, co pozwoliło na odwrócenie inżynierii i wdrożenie obsługi wielu różnych aparatów cyfrowych. Obsługuje on kilka protokołów, ale najbardziej interesował mnie protokół USB, który działa za pomocą biblioteki libusb.

Opis tworzenia tej wersji demonstracyjnej podzielę na 2 części. W tym wpisie na blogu opisuję, jak przeportowałem bibliotekę libusb i jakie sztuczki mogą być potrzebne do przeportowania innych popularnych bibliotek do interfejsów Fugu API. W drugim poście omówię szczegółowo przenoszenie i integrację gPhoto2.

Skorzystałem z aplikacji internetowej, która wyświetla podgląd transmisji na żywo z lustrzanki cyfrowej i może sterować jej ustawieniami przez USB. Zanim przeczytasz szczegóły techniczne, możesz obejrzeć demo na żywo lub nagrane wcześniej:

Demo na laptopie połączonym z kamerą Sony.

Uwaga na problemy związane z konkretną kamerą

Jak już pewnie wiesz, zmiana ustawień w filmie zajmuje trochę czasu. Podobnie jak w przypadku większości innych problemów, które mogą się pojawić, nie jest to spowodowane wydajnością WebAssembly ani WebUSB, ale sposobem, w jaki gPhoto2 współdziała z określoną kamerą wybraną na potrzeby demonstracji.

Aparat Sony a6600 nie udostępnia interfejsu API do bezpośredniego ustawiania wartości takich jak ISO, przysłona czy czas otwarcia migawki. Zamiast tego udostępnia tylko polecenia zwiększania lub zmniejszania tych wartości o określoną liczbę kroków. Co więcej, nie zwraca ona listy obsługiwanych wartości – zwracana lista wydaje się być zakodowana na stałe w wielu modelach aparatów Sony.

Podczas ustawiania jednej z tych wartości gPhoto2 nie ma innego wyboru niż:

  1. Zmień wartość o jeden lub kilka kroków w kierunku wybranej wartości.
  2. Zaczekaj chwilę, aż aparat zaktualizuje ustawienia.
  3. Odczytaj ponownie wartość, do której wylądowała kamera.
  4. Sprawdź, czy ostatni krok nie przeskoczył do żądanej wartości ani nie jest zawinięty na końcu lub na początku listy.
  5. Powtórz.

Może to trochę potrwać, ale jeśli wartość jest faktycznie obsługiwana przez kamerę, dotrze do celu, a w przeciwnym razie zatrzyma się po najbliższej obsługiwanej wartości.

Inne kamery będą prawdopodobnie mieć inne zestawy ustawień, interfejsów API i dziwacznych zachowań. Pamiętaj, że gPhoto2 to projekt open source, a automatyczne lub ręczne testowanie wszystkich dostępnych modeli aparatów jest po prostu niemożliwe, dlatego zawsze mile widziane są szczegółowe raporty o problemach i PR-y (pamiętaj jednak, aby najpierw odtworzyć problemy w oficjalnym kliencie gPhoto2).

Ważne informacje o zgodności na różnych platformach

Niestety w systemie Windows każde „znane” urządzenie, w tym lustrzanki cyfrowe, ma przypisany sterownik systemowy, który nie jest zgodny z WebUSB. Jeśli chcesz wypróbować wersję demonstracyjną w systemie Windows, musisz skorzystać z narzędzia takiego jak Zadig, by zastąpić sterownik podłączonej lustrzanki cyfrowej WinUSB lub libusb. To podejście sprawdza się w przypadku wielu użytkowników, ale korzystanie z niego odbywa się na własne ryzyko.

W Linuxie prawdopodobnie trzeba będzie ustawić niestandardowe uprawnienia, aby umożliwić dostęp do aparatu DSLR przez WebUSB. Zależy to jednak od dystrybucji.

W systemach macOS i Android wersja demonstracyjna powinna działać od razu. Jeśli próbujesz użyć aplikacji na telefonie z Androidem, przełącz na tryb poziomy, ponieważ nie włożyłam zbyt wiele wysiłku w utworzenie responsywnej wersji (zapraszam do kontaktu z działem PR):

Telefon z Androidem podłączony do aparatu Canon kablem USB-C.
To samo demo na telefonie z Androidem. Zdjęcie: Surma.

Szczegółowe informacje o używaniu interfejsu WebUSB na wielu platformach znajdziesz w sekcji „Wskazówki dotyczące poszczególnych platform” w artykule „Tworzenie urządzenia do obsługi interfejsu WebUSB”.

Dodawanie nowego backendu do libusb

Teraz przejdźmy do szczegółów technicznych. Chociaż można udostępnić interfejs API podobny do libusb (co zostało już zrobione przez innych) i połączyć z nim inne aplikacje, takie podejście jest podatne na błędy i utrudnia dalsze rozszerzanie lub konserwację. Chciałam zrobić wszystko dobrze, w sposób, który mógłby być w przyszłości trafiać z powrotem do źródła i w przyszłości być scalony w libus.

Na szczęście w README libusb jest napisane:

„libusb jest abstrakcją wewnętrzną, która – miejmy nadzieję – może zostać przeniesiona do innych systemów operacyjnych. Więcej informacji znajdziesz w pliku PORTING.

Biblioteka libusb jest skonstruowana w taki sposób, że publiczny interfejs API jest oddzielony od „back-endów”. Te backendy odpowiadają za wyświetlanie, otwieranie i zamykanie urządzeń oraz za komunikację z urządzeniami przez niskopoziomowe interfejsy API systemu operacyjnego. W ten sposób libusb abstrahuje od różnic między systemami Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku i Solaris i działa na wszystkich tych platformach.

Musiałem dodać kolejny backend dla „systemu operacyjnego” Emscripten+WebUSB. Implementacje tych backendów znajdują się w folderze 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

Każdy backend zawiera nagłówek libusbi.h z popularnymi typami i pomocami. Musi też udostępniać zmienną usbi_backend typu usbi_os_backend. Oto przykład back-endu Windows:

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

Przeglądając właściwości, widać, że struktura zawiera nazwę backendu, zbiór jego funkcji, moduły obsługi różnych niskopoziomowych operacji USB w postaci wskaźników funkcji, a na koniec rozmiary, które można przydzielić do przechowywania prywatnych danych na poziomie urządzenia, kontekstu lub transferu.

Pola danych prywatnych są przydatne przynajmniej do przechowywania identyfikatorów interfejsów systemu operacyjnego dla wszystkich tych elementów, ponieważ bez nich nie wiemy, do którego elementu odnosi się dana operacja. W implementacji internetowej uchwyty systemu operacyjnego to bazowe obiekty JavaScript WebUSB. Naturalnym sposobem ich reprezentowania i przechowywania w Emscripten jest klasa emscripten::val, która jest dostarczana w ramach Embind (systemu powiązań Emscripten).

Większość backendów w tym folderze jest napisana w C, ale niektóre w C++. Embind działa tylko z C++, więc wybór został dokonany za mnie i dodałem libusb/libusb/os/emscripten_webusb.cpp z wymaganą strukturą i z sizeof(val) dla prywatnych pól danych:

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

Przechowywanie obiektów WebUSB jako uchwytów urządzenia

libusb udostępnia gotowe wskaźniki do zarezerwowanego obszaru danych prywatnych. Aby można było pracować z tymi wskaźnikami jako instancjami val, dodałem małe pomocnicze funkcje, które je tworzą na miejscu, pobierają jako odwołania i przenoszą wartości:

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

Asynchroniczne interfejsy API sieciowe w synchronicznych kontekstach C

Potrzebny jest teraz sposób obsługi asynchronicznych interfejsów API WebUSB, w których przypadku libusb oczekuje operacji synchronicznych. W tym celu mogę użyć pakietu Asyncify, a ściślej mówiąc jego integracji z Embind za pomocą val::await().

Chciałem też prawidłowo obsługiwać błędy WebUSB i konwertować je na kody błędów libusb, ale Embind nie ma obecnie możliwości obsługi wyjątków JavaScript ani odrzuceń Promise po stronie C++. Ten problem można obejść, przechwytując odrzucenie po stronie JavaScript i konwertując wynik na obiekt { error, value }, który można teraz bezpiecznie przeanalizować po stronie C++. Udało mi się to zrobić przy użyciu makra EM_JS i interfejsów API Emval.to{Handle, Value}:

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

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

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

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

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

Teraz mogę użyć polecenia promise_result::await() na dowolnym elemencie Promise zwróconym z operacji WebUSB i sprawdzić jego pola error oraz value oddzielnie.

Na przykład pobranie z libusb_device_handle pola val reprezentującego USBDevice, wywołanie jego metody open(), oczekiwanie na wynik i zwrócenie kodu błędu w postaci kodu stanu libusb wygląda tak:

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

Wyliczenie urządzeń

Oczywiście, zanim otworzymy jakiekolwiek urządzenie, libusb musi pobrać listę dostępnych urządzeń. Backend musi implementować tę operację za pomocą obsługi get_device_list.

Problem polega na tym, że w odróżnieniu od innych platform nie ma możliwości zliczania wszystkich podłączonych urządzeń USB w internecie ze względów bezpieczeństwa. Zamiast tego proces jest podzielony na 2 części. Najpierw aplikacja internetowa prosi o urządzenia o określonych właściwościach za pomocą navigator.usb.requestDevice(), a użytkownik ręcznie wybiera urządzenie, które chce udostępnić, lub odrzuca prośbę o uprawnienia. Następnie aplikacja wyświetla listę urządzeń, które zostały już zatwierdzone i połączone za pomocą navigator.usb.getDevices().

Na początku próbowałem użyć requestDevice() bezpośrednio w implementacji elementu obsługi get_device_list. Wyświetlanie prośby o przyznanie uprawnień z listą podłączonych urządzeń jest jednak uważane za działanie wrażliwe i musi być wywoływane przez użytkownika (np. przez kliknięcie przycisku na stronie). W przeciwnym razie zawsze zwraca odrzucone obietnice. Aplikacje korzystające z biblioteki libusb często chcą wyświetlać listę podłączonych urządzeń po uruchomieniu aplikacji, więc użycie requestDevice() nie było możliwe.

Musiałem jednak przekazać wywołanie navigator.usb.requestDevice() deweloperowi i udostępnić tylko zatwierdzone urządzenia z platformy navigator.usb.getDevices():

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

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

Większość kodu backendu używa funkcji val i promise_result w podobny sposób jak pokazano powyżej. W kodzie obsługującym transfer danych jest kilka ciekawych sztuczek, ale szczegóły implementacji są mniej istotne w ramach tego artykułu. Jeśli chcesz, możesz sprawdzić kod i komentarze na GitHubie.

Przenoszenie pętli zdarzeń do sieci

Jeszcze jeden element portu Libusb, o którym chcę wspomnieć, to obsługa zdarzeń. Jak opisano w poprzednim artykule, większość interfejsów API w językach systemowych, takich jak C, jest synchroniczna, a obsługa zdarzeń nie stanowi wyjątku. Jest on zwykle implementowany za pomocą nieskończonej pętli, która „przeprowadza sondowanie” (próbuje odczytać dane lub blokuje wykonanie, dopóki nie będą dostępne jakieś dane) z zestawu zewnętrznych źródeł danych wejściowych i wyjściowych, a gdy co najmniej jedno z nich odpowie, przekazuje to jako zdarzenie do odpowiedniego modułu obsługi. Gdy przetwarzanie zostanie zakończone, kontrola wraca do pętli i zatrzymuje się na kolejnym odczycie.

W przypadku tego podejścia w internecie występuje kilka problemów.

Po pierwsze, WebUSB nie udostępnia nieprzetworzonych uchwytów urządzeń źródłowych, więc odpytywanie ich bezpośrednio nie jest możliwe. Po drugie libusb korzysta z interfejsów API eventfd i pipe do obsługi innych zdarzeń oraz do obsługi transferów w systemach operacyjnych bez surowych uchwytów urządzeń. Jednak wersja eventfd nie jest obecnie obsługiwana w Emscripten ani pipe, mimo że obecnie nie jest zgodna ze specyfikacją i nie może czekać na zdarzenia.

Największym problemem jest to, że sieć ma własny cykl zdarzeń. Ten globalny cykl zdarzeń jest używany do wszelkich operacji zewnętrznych we/wy (w tym fetch(), timerów i w tym przypadku WebUSB) i wywołuje metody obsługi zdarzeń lub Promise po zakończeniu odpowiednich operacji. Wykonywanie kolejnej, zagnieżdżonej, nieskończonej pętli zdarzeń spowoduje zablokowanie pętli zdarzeń przeglądarki, co oznacza, że interfejs użytkownika nie tylko przestanie reagować, ale też kod nigdy nie otrzyma powiadomień o tych samych zdarzeniach wejścia/wyjścia, na które czeka. Zwykle kończy się to blokadą, co miało miejsce również podczas próby użycia libusb w demo. Strona się zawiesiła.

Podobnie jak w przypadku innych blokujących operacji wejścia/wyjścia, aby przenieść takie pętle zdarzeń do sieci, deweloperzy muszą znaleźć sposób na ich uruchamianie bez blokowania wątku głównego. Jednym ze sposobów jest refaktoryzacja aplikacji pod kątem obsługi zdarzeń wejścia-wyjścia w osobnym wątku i przekazywanie wyników z powrotem do głównego wątku. Druga to użycie Asyncify do wstrzymania pętli i oczekiwania na zdarzenia w sposób niezablokowany.

Nie chciałem wprowadzać istotnych zmian w libusb ani gPhoto2. Używam już Asyncify do integracji z Promise, więc wybrałem tę ścieżkę. Aby zasymulować wariant blokujący funkcji poll(), na potrzeby wstępnego potwierdzenia koncepcji użyłem pętli, jak pokazano poniżej:

#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

Do czego służy:

  1. Wywołuje funkcję poll(), aby sprawdzić, czy backend zgłosił już jakieś zdarzenia. Jeśli tak, pętla się zatrzymuje. W przeciwnym razie implementacja poll() w Emscripten zostanie natychmiast zwrócona z parametrem 0.
  2. Dzwoni emscripten_sleep(0). Ta funkcja korzysta z funkcji Asyncify i setTimeout(), aby zwrócić kontrolę do głównej pętli zdarzeń przeglądarki. Pozwala to przeglądarce obsługiwać wszelkie interakcje z użytkownikiem i zdarzenia wejścia/wyjścia, w tym WebUSB.
  3. Sprawdź, czy określony czas oczekiwania już upłynął, a jeśli nie, kontynuuj pętlę.

Jak wspomniano w komentarzu, to podejście nie było optymalne, ponieważ cały stos wywołań był zapisywany i przywracany za pomocą Asyncify nawet wtedy, gdy nie było jeszcze żadnych zdarzeń USB do obsłużenia (co zdarza się najczęściej), a sam setTimeout() ma minimalny czas trwania wynoszący 4 ms w nowoczesnych przeglądarkach. Mimo to w ramach testu koncepcji udało się uzyskać 13–14 FPS podczas transmisji na żywo z kamery DSLR.

Później postanowiłem go ulepszyć, korzystając z systemu zdarzeń przeglądarki. Jest kilka sposobów na ulepszenie tej implementacji, ale na razie wybrałem wysyłanie zdarzeń niestandardowych bezpośrednio do obiektu globalnego, bez powiązania ich z konkretną strukturą danych libusb. Używam do tego mechanizmu oczekiwania i powiadamiania opartego na makro EM_ASYNC_JS:

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

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

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

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

Funkcja em_libusb_notify() jest używana, gdy libusb próbuje zgłosić zdarzenie, takie jak zakończenie przesyłania danych:

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
}

Z kolei część em_libusb_wait() służy do „wybudzania” z trybu uśpienia asynchronicznego po otrzymaniu zdarzenia em-libusb lub po upływie czasu oczekiwania:

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

Dzięki znacznemu skróceniu czasu trwania funkcji sleep i wake-up ten mechanizm rozwiązał problemy z wydajnością wcześniejszej implementacji opartej na emscripten_sleep() oraz zwiększył przepustowość demo DSLR z 13–14 FPS do stabilnych 30 FPS i więcej, co wystarcza do płynnego przesyłania danych na żywo.

Tworzenie systemu i pierwszy test

Po zakończeniu pracy nad backendem musiałem dodać go do Makefile.am i configure.ac. Jedynym interesującym elementem jest modyfikacja flag specyficznych dla Emscripten:

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

Po pierwsze, pliki wykonywalne na platformach Unix zwykle nie mają rozszerzeń. Emscripten generuje jednak różne dane wyjściowe w zależności od tego, którego rozszerzenia dotyczy Twoje żądanie. Używam AC_SUBST(EXEEXT, …) do zmiany rozszerzenia wykonywalnego na .html, tak aby każdy plik wykonywalny w pakiecie – testy i przykłady – stał się kodem HTML z domyślną powłoką Emscripten, która zajmuje się wczytywaniem i tworzeniem JavaScriptu oraz WebAssembly.

Po drugie, ponieważ używam Embind i Asyncify, muszę włączyć te funkcje (--bind -s ASYNCIFY), a także zezwolić na dynamiczne zwiększanie pamięci (-s ALLOW_MEMORY_GROWTH) za pomocą parametrów linkera. Niestety biblioteka nie może przekazać tych flag linkerowi, więc każda aplikacja korzystająca z portu libusb musi dodać te same flagi linkera do swojej konfiguracji kompilacji.

Jak już wspomnieliśmy, WebUSB wymaga, aby zliczanie urządzeń było wykonywane przez użytkownika. Przykłady i testy libusb zakładają, że mogą zliczać urządzenia podczas uruchamiania, a w przeciwnym razie kończy się to błędem. Zamiast tego musiałem wyłączyć automatyczne wykonywanie (-s INVOKE_RUN=0) i ujawnić ręczną metodę callMain() (-s EXPORTED_RUNTIME_METHODS=...).

Po wykonaniu tych czynności mogłem udostępniać wygenerowane pliki za pomocą stałego serwera WWW, zainicjować WebUSB i ręcznie uruchamiać pliki HTML za pomocą DevTools.

Zrzut ekranu pokazujący okno Chrome z otwartymi Narzędziami deweloperskimi na stronie testlibusb serwowanej lokalnie Konsola DevTools analizuje wyrażenie `navigator.usb.requestDevice({ filters: [] })`, które spowodowało wyświetlenie prośby o zgodę. Obecnie prosi użytkownika o wybranie urządzenia USB, które ma być udostępnione stronie. Obecnie wybrany jest aparat ILCE-6600 (aparat Sony).

Zrzut ekranu z kolejną czynnością, w tle widoczne są Narzędzia deweloperskie. Po wybraniu urządzenia konsola zinterpretowała nowy wyrażenie `Module.callMain([&#39;-v&#39;])`, które uruchomiło aplikację `testlibusb` w trybie szczegółowym. Wyjście zawiera różne szczegółowe informacje o poprzednio podłączonej kamerze USB: producent Sony, model ILCE-6600, numer seryjny, konfigurację itp.

Nie wygląda to dużo, ale gdy przenosisz biblioteki na nową platformę, dotarcie do etapu, na którym po raz pierwszy pojawiają się prawidłowe wyniki, jest fascynujące.

Korzystanie z portu

Jak wspomnieliśmy powyżej, portowanie zależy od kilku funkcji Emscripten, które obecnie muszą być włączone na etapie łączenia aplikacji. Jeśli chcesz użyć tego portu libusb w swojej aplikacji, wykonaj te czynności:

  1. Pobierz najnowszą wersję libusb jako archiwum jako część kompilacji lub dodaj ją jako moduł podrzędny git w swoim projekcie.
  2. Uruchom autoreconf -fiv w folderze libusb.
  3. Uruchom emconfigure ./configure –host=wasm32 –prefix=/some/installation/path, aby zainicjować projekt na potrzeby kompilacji krzyżowej i ustawić ścieżkę, w której chcesz umieścić kompilowane artefakty.
  4. Uruchom emmake make install.
  5. Wskazywanie aplikacji lub biblioteki wyższego poziomu w celu wyszukania biblioteki libusb w wybranym wcześniej katalogu.
  6. Dodaj do argumentów linku w aplikacji te flagi: --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH.

Biblioteka ma obecnie kilka ograniczeń:

  • Nie ma możliwości anulowania przeniesienia. Jest to ograniczenie WebUSB, które z kolei wynika z braku anulowania transferu między platformami w samej bibliotece libusb.
  • Brak obsługi transferu synchronicznego. Dodanie tego trybu nie powinno być trudne po zastosowaniu istniejących trybów transferu jako przykładów, ale tryb ten jest też dość rzadki, a nie miałem(-am) żadnych urządzeń, na których go testowałem, więc na razie pozostawiłem go jako nieobsługiwany. Jeśli masz takie urządzenia i chcesz dodać je do biblioteki, chętnie je przyjmiemy.
  • Wcześniej wspomniane ograniczenia obejmujące wiele platform. Ograniczenia te są narzucane przez systemy operacyjne, więc nie możemy wiele zrobić, poza poproszeniem użytkowników o zastąpienie sterownika lub uprawnień. Jeśli jednak portujesz urządzenia HID lub szeregowe, możesz skorzystać z przykładu libusb i przeportować inną bibliotekę do innego interfejsu Fugu API. Możesz na przykład przenieść bibliotekę C hidapi do WebHID i w ten sposób całkowicie uniknąć problemów związanych z dostępem do interfejsu USB na niskim poziomie.

Podsumowanie

W tym poście pokazałem, jak za pomocą interfejsów Emscripten, Asyncify i Fugu API nawet biblioteki niskiego poziomu, takie jak libusb, można przenieść do sieci przy użyciu kilku sztuczek integracyjnych.

Przenoszenie takich niezbędnych i powszechnie używanych bibliotek niskiego poziomu jest szczególnie opłacalne, ponieważ pozwala przenieść do sieci biblioteki wyższego poziomu, a nawet całe aplikacje. Dzięki temu funkcje, które były wcześniej dostępne tylko dla użytkowników jednej lub dwóch platform, będą dostępne na wszystkich urządzeniach i w różnych systemach operacyjnych. Wystarczy kliknąć link.

W następnym poście omówię proces tworzenia wersji demonstracyjnej gPhoto2 w przeglądarce, która nie tylko pobiera informacje o urządzeniu, ale też intensywnie korzysta z funkcji przesyłania w libusb. Mam nadzieję, że ten przykład libusb był dla Ciebie inspiracją i przetestujesz wersję demonstracyjną, pobawisz się samą biblioteką, a może nawet przeniesiesz inną powszechnie używaną bibliotekę do jednego z interfejsów API Fugu.