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 pokazaliśmy, jak przenosić do internetu aplikacje za pomocą interfejsów API systemu plików przy użyciu interfejsów 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.

Zacznijmy od początku: prezentacja

Najważniejszą rzeczą do zrobienia podczas przenoszenia biblioteki jest wybranie odpowiedniej wersji demonstracyjnej, która podkreśli możliwości przeniesionej biblioteki, umożliwi przetestowanie jej na różne sposoby i jednocześnie będzie atrakcyjna wizualnie.

Wybrałem pilota na lustrzankę cyfrową. Projekt open source gPhoto2 działa w tej dziedzinie na tyle długo, że może przeprowadzać analizę wsteczną i wdrażać obsługę szerokiej gamy aparatów cyfrowych. Obsługuje kilka protokołów, ale najbardziej interesowała mnie obsługa USB (wykorzystywana przez libusb).

Etapy tworzenia tej wersji demonstracyjnej przedstawię w 2 częściach. W tym poście omówię, jak udało mi się przenieść bibliotekę libusb i jakie sztuczki trzeba będzie wykonać, aby przenieść inne popularne biblioteki do interfejsów API Fugu. W drugim poście omówię szczegółowo przenoszenie i integrowanie programu 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 zapoznasz się ze szczegółami technicznymi, obejrzyj prezentację na żywo lub nagranie nagrane wcześniej:

Prezentacja uruchomiona na laptopie podłączonym do kamery Sony.

Uwaga dotycząca funkcji związanych z aparatem

Jak już pewnie wiesz, zmiana ustawień w filmie zajmuje trochę czasu. Podobnie jak w przypadku większości innych problemów, nie jest to spowodowane wydajnością WebAssembly ani WebUSB, tylko tym, jak gPhoto2 współpracuje z aparatem wybranym na potrzeby wersji demonstracyjnej.

Sony a6600 nie udostępnia interfejsu API do bezpośredniego ustawiania wartości takich jak ISO, przysłona czy szybkość migawki. Zamiast tego udostępnia polecenia zwiększające lub zmniejszające te wartości o określoną liczbę kroków. Aby komplikować sprawę, nie zawiera też listy rzeczywiście obsługiwanych wartości – zwracana lista może być zakodowana na stałe w wielu modelach aparatów Sony.

Gdy ustawisz jedną z tych wartości, gPhoto2 ma wyłącznie następujące możliwości:

  1. Wykonaj krok (lub kilka kroków) w kierunku wybranej wartości.
  2. Poczekaj, aż aparat zaktualizuje ustawienia.
  3. Odczytaj wartość, do której faktycznie wylądowała kamera.
  4. Sprawdź, czy ostatni krok nie przeszedł do żądanej wartości ani nie został zawinięty na końcu lub początku listy.
  5. Powtórz.

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

Inne kamery mają zapewne inne zestawy ustawień, podstawowe interfejsy API i nietypowe rozwiązania. 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 na temat zgodności z wieloma platformami

Niestety w systemie Windows wszystkie „dobrze znane” urządzenia, w tym lustrzanki cyfrowe, mają przypisany sterownik systemu, 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. Ta metoda sprawdza się w moim przypadku oraz dla wielu innych użytkowników, ale należy ją stosować na własne ryzyko.

W Linuksie może być konieczne ustawienie uprawnień niestandardowych, aby umożliwić dostęp do lustrzanki cyfrowej przez WebUSB, choć zależy to od używanej dystrybucji.

W systemach macOS i Android wersja demonstracyjna powinna działać od razu. Jeśli testujesz tę funkcję na telefonie z Androidem, pamiętaj, by przełączyć ją na tryb poziomy, ponieważ nie włożyłem wiele pracy w to, by działał prawidłowo (możesz liczyć na PR):

Telefon z Androidem podłączony do aparatu Canon kablem USB-C.
Ta sama wersja demonstracyjna uruchamiana na telefonie z Androidem. Zdjęcie: Surma.

Szczegółowy przewodnik po korzystaniu z WebUSB na wielu platformach znajdziesz w artykule „Uwagi na temat poszczególnych platform” artykułu „Tworzenie urządzenia na potrzeby WebUSB”.

Dodawanie nowego backendu do libusb

Przejdźmy do szczegółów technicznych. Chociaż można udostępnić interfejs API podkładki (podobny do libusb) i połączyć z nim inne aplikacje, takie podejście jest podatne na błędy i utrudnia dalsze rozszerzanie i 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 libusb README informuje:

„libusb został wyodrębniony wewnętrznie w taki sposób, że być może uda się go przenieść na inne systemy operacyjne. Więcej informacji znajdziesz w pliku PORTING”.

Biblioteka libusb ma strukturę oddzieloną od „backendó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 już teraz eliminuje różnice między systemami Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku i Solaris, i działa na wszystkich tych platformach.

Musiałem dodać kolejny backend do „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 typowymi typami i elementami pomocniczymi oraz musi udostępniać zmienną usbi_backend typu usbi_os_backend. Tak na przykład wygląda backend systemu 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 prywatnych danych są przydatne przynajmniej do przechowywania uchwytów systemu operacyjnego we wszystkich tych przypadkach, ponieważ bez nicków nie wiemy, których elementów dotyczy dana operacja. W implementacji internetowej uchwyty systemu operacyjnego to bazowe obiekty JavaScript WebUSB. Naturalnym sposobem ich przedstawienia i przechowywania w Emscripten jest użycie klasy emscripten::val, która jest częścią Embind (systemu powiązań Emscripten).

Większość backendów w folderze jest zaimplementowana w C, ale kilka jest w C++. Embind działa tylko w C++, więc wybór został wybrany za mnie – dodałem libusb/libusb/os/emscripten_webusb.cpp z wymaganą strukturą i sizeof(val) w polach prywatnych 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 do użycia wskaźniki do odpowiedniego obszaru na potrzeby danych prywatnych. Aby umożliwić pracę z tymi wskaźnikami jako instancjami val, dodaliśmy niewielkie pomocniki, które tworzą je na miejscu, pobierają je jako odwołania i przenoszą wartości na zewnątrz:

// 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 w synchronicznych kontekstach C

Potrzebowaliśmy teraz sposobu obsługi asynchronicznych interfejsów WebUSB API, w których libusb oczekuje operacji synchronicznych. Do tego celu mogę użyć usługi Asyncify, a dokładniej jej integracji z Embind przez val::await().

Zależało mi też na poprawnej obsłudze błędów WebUSB i przekształcaniu ich w kody libusb, ale Embind nie ma obecnie możliwości obsługi wyjątków JavaScriptu ani odrzuceń Promise ze strony C++. Aby obejść ten problem, można wykryć odrzucenie po stronie JavaScriptu i przekonwertować wynik na obiekt { error, value }, który można teraz bezpiecznie przeanalizować z poziomu języka 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 elementu 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 otworzę dowolne urządzenie, Libusb musi pobrać listę dostępnych urządzeń. Backend musi zaimplementować tę operację za pomocą modułu obsługi get_device_list.

Trudność polega na tym, że w przeciwieństwie do innych platform nie można wymienić wszystkich urządzeń USB podłączonych do sieci ze względów bezpieczeństwa. Zamiast tego proces zostaje podzielony na dwie części. Najpierw za pomocą interfejsu navigator.usb.requestDevice() aplikacja internetowa wysyła żądania dotyczące urządzeń z określonymi właściwościami, a użytkownik ręcznie wybiera urządzenie, które chce wyświetlić, lub odrzuca prośbę o przyznanie uprawnień. Następnie aplikacja wyświetla listę zatwierdzonych i połączonych urządzeń na stronie navigator.usb.getDevices().

Na początku próbowałem użyć requestDevice() bezpośrednio w implementacji modułu obsługi get_device_list. Jednak wyświetlenie prośby o przyznanie uprawnień wraz z listą połączonych urządzeń jest uznawane za operację poufną i musi być aktywowana w wyniku interakcji użytkownika (np. kliknięcia przycisku na stronie). W przeciwnym razie zawsze zwraca odrzuconą obietnicę. Aplikacje libusb mogą często chcieć wyświetlać połączone urządzenia podczas uruchamiania 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 val i promise_result w podobny sposób, jak pokazano powyżej. W kodzie obsługi przesyłania danych jest znacznie więcej ciekawych sposobów hakowania, ale w kontekście tego artykułu są one mniej ważne. Jeśli chcesz dowiedzieć się więcej, sprawdź kod i komentarze w GitHubie.

Przenoszenie zdarzeń do internetu

Kolejnym aspektem portu Libusb, który chcę omówić, jest obsługa zdarzeń. Jak opisano w poprzednim artykule, większość interfejsów API w językach systemowych, takich jak C, jest synchroniczna i obsługa zdarzeń nie jest wyjątkiem. Zwykle jest implementowana w nieskończonej pętli, która polega na „ankietach” (próbuje odczytać dane lub blokuje wykonywanie, dopóki część danych nie będzie dostępna) z zestawu zewnętrznych źródeł wejścia-wyjścia, a gdy co najmniej jedno z tych źródeł odpowie, przekaże to jako zdarzenie do odpowiedniego modułu obsługi. Po zakończeniu działania element sterujący powraca do pętli i wstrzymuje działanie na potrzeby kolejnej ankiety.

Takie podejście w internecie wiąże się z pewnymi problemami.

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 standard eventfd nie jest obecnie obsługiwany w Emscripten i pipe, mimo że obecnie nie jest zgodny ze specyfikacją i nie może czekać na zdarzenia.

Największym problemem jest to, że internet ma własną pętlę zdarzeń. Ta globalna pętla zdarzeń jest wykorzystywana we wszystkich zewnętrznych operacjach wejścia-wyjścia (w tym fetch(), licznikach czasu, a w tym przypadku – WebUSB) i wywołuje moduły obsługi zdarzeń lub Promise, gdy odpowiednie operacje się zakończą. Ponowna zagnieżdżona, nieskończona pętla zdarzeń zablokuje dalsze wykonywanie pętli zdarzeń przeglądarki, co oznacza, że interfejs nie tylko przestanie reagować, ale również kod nie będzie otrzymywać powiadomień o tych samych zdarzeniach wejścia-wyjścia, na które czeka. Zwykle prowadzi to do zakleszczeń. Tak było też przy próbie użycia libusb w wersji demonstracyjnej. 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 uruchomienie tych pętli 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. Drugi to użycie funkcji Asyncify do wstrzymania pętli i oczekiwania na zdarzenia w sposób nieblokujący.

Nie chciałem wprowadzać znaczących zmian w programie libusb ani gPhoto2, a korzystałem już z integracji z usługą Asyncify w Promise, więc wybrałem tę ścieżkę. Aby przeprowadzić symulację blokującego wariantu poll(), we wstępnym modelu koncepcyjnym użyliśmy pętli, jak 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 jakieś zdarzenia zostały już zgłoszone przez backend. Jeśli jakieś są, pętla zatrzymuje się. W przeciwnym razie implementacja poll() w Emscripten zostanie natychmiast zwrócona z parametrem 0.
  2. Dzwoni emscripten_sleep(0). Ta funkcja korzysta z wewnętrznych narzędzi Asyncify i setTimeout(), aby umożliwić kontrolę nad główną pętlą zdarzeń przeglądarki. Dzięki temu przeglądarka może obsługiwać dowolne interakcje z użytkownikiem i zdarzenia I/O, w tym WebUSB.
  3. Sprawdź, czy określony czas oczekiwania już upłynął, a jeśli nie, kontynuuj pętlę.

Jak wspomniano w komentarzu, takie podejście nie było optymalne, ponieważ narzędzie Asyncify reagowało na zapisywanie całego stosu wywołań, nawet gdy nie było jeszcze żadnych zdarzeń USB do obsłużenia (a w większości przypadków) oraz dlatego, że w nowoczesnych przeglądarkach czas trwania setTimeout() wynosi minimalny 4 ms. Mimo to okazało się, że jako model koncepcyjny udało się nakręcić transmisję na żywo w tempie 13–14 kl./s przy użyciu lustrzanki cyfrowej.

Później postanowiłem ulepszyć tę funkcję, wykorzystując system zdarzeń przeglądarki. Jest kilka sposobów na ulepszenie tej implementacji, ale na razie zdecydowałem się wysyłać zdarzenia niestandardowe bezpośrednio do obiektu globalnego, nie wiążąc ich z konkretną strukturą danych libusb. Zrobiłem to, korzystając z tego mechanizmu oczekiwania i powiadomienia opartego na makrze 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 za każdym razem, 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
}

Tymczasem do „wybudzania” służy część em_libusb_wait() w wyniku Asynchronizacji snu po otrzymaniu zdarzenia em-libusb lub upłynięciu 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;
}

Ze względu na znaczne skrócenie czasu zasypiania i wybudzeń mechanizm ten rozwiązał problemy z wydajnością wcześniejszej implementacji opartej na emscripten_sleep() i zwiększył przepustowość lustrzanki cyfrowej z 13–14 FPS do stabilnej wartości powyżej 30 FPS, co wystarcza do płynnego wyświetlania transmisji na żywo.

Kompilacja systemu i pierwszy test

Gdy backend był gotowy, trzeba było dodać go do interfejsów Makefile.am i configure.ac. Jedynym interesującym fragmentem jest modyfikacja flag w sposób specyficzny 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ń. Element Emscripten generuje jednak różne dane wyjściowe w zależności od rozszerzenia, którego chcesz użyć. 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 umożliwić dynamiczny wzrost pamięci (-s ALLOW_MEMORY_GROWTH) za pomocą parametrów tagu łączącego. Niestety nie ma możliwości zgłaszania tych flag do tagu łączącego, więc każda aplikacja korzystająca z tego portu libusb musi dodać te same flagi tagu łączącego do konfiguracji kompilacji.

Jak już wspomnieliśmy, WebUSB wymaga, aby urządzenia można było wyliczać gestami użytkownika. W przykładach i testach libusb zakładamy, że są w stanie wyliczyć urządzenia podczas uruchamiania i występować w nich błędy bez wprowadzania zmian. Musiałem więc wyłączyć automatyczne wykonywanie (-s INVOKE_RUN=0) i udostępnić ręczną metodę callMain() (-s EXPORTED_RUNTIME_METHODS=...).

Gdy to zrobisz, będzie można wyświetlić wygenerowane pliki na statycznym serwerze WWW, zainicjować WebUSB i ręcznie uruchomić te pliki wykonywalne HTML, korzystając z Narzędzi deweloperskich.

Zrzut ekranu przedstawiający okno Chrome z otwartymi Narzędziami deweloperskimi na obsługiwanej lokalnie stronie „testlibusb”. Konsola w Narzędziach deweloperskich sprawdza działanie `navigator.usb.requestDevice({ filters: [] })`, co spowodowało wyświetlenie prośby o przyznanie uprawnień i użytkownik otrzymuje obecnie prośbę o wybranie urządzenia USB, które powinno zostać udostępnione stronie. Obecnie wybrany jest aparat ILCE-6600 (aparat Sony).

Zrzut ekranu pokazujący następny krok z otwartymi Narzędziami deweloperskimi. Po wybraniu urządzenia konsola sprawdziła nowe wyrażenie `Module.callMain([&#39;-v&#39;])`, które uruchomiło aplikację `testlibusb` w trybie szczegółowym. Na wyjściu wyświetlają się różne szczegółowe informacje o wcześniej podłączonej kamerze USB: producent Sony, produkt ILCE-6600, numer seryjny, konfiguracja 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 bardzo ekscytujące.

Korzystanie z portu

Jak wspomnieliśmy powyżej, port zależy od kilku funkcji Emscripten, które muszą być obecnie 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. Skieruj aplikację lub bibliotekę wyższego poziomu, aby wyszukać libusb pod wcześniej wybraną ścieżką.
  6. Dodaj te flagi do argumentów linku aplikacji: --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH.

Biblioteka ma obecnie kilka ograniczeń:

  • Brak możliwości anulowania przeniesienia. Jest to ograniczenie WebUSB, które z kolei wynika z braku anulowania transferu między platformami w samej Libusb.
  • Brak obsługi przenoszenia izochronologicznego. Dodanie tego trybu nie powinno być trudne po wykonaniu przykładów z istniejących trybów transferu. 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(-am) go jako nieobsługiwany. Jeśli masz takie urządzenia i chcesz pomóc w rozwijaniu biblioteki, zachęcamy do kontaktu w tej sprawie.
  • Wcześniej wspomniane ograniczenia obejmujące wiele platform. Te ograniczenia są nakładane przez systemy operacyjne, więc nie możemy w tym przypadku nic zrobić, chyba że poprosimy użytkownika o zastąpienie sterownika lub uprawnień. Jeśli jednak przenosisz urządzenia HID lub szeregowe, możesz skorzystać z przykładu libusb i przenieść inną bibliotekę do innego interfejsu Fugu API. Można na przykład przenieść bibliotekę C hidapi do WebHID i całkowicie wyeliminować problemy związane z niskim dostępem do urządzeń USB.

Podsumowanie

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

Przeniesienie tak ważnych i powszechnie używanych bibliotek niskiego poziomu jest szczególnie satysfakcjonujące, ponieważ z kolei umożliwia przeniesienie do sieci bibliotek wyższego poziomu, a nawet całych aplikacji. Dzięki temu funkcje, które wcześniej były 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, dzięki czemu będą dostępne po kliknięciu linku.

W następnym poście omówię proces tworzenia internetowej wersji demonstracyjnej programu gPhoto2, która nie tylko pobiera informacje z urządzenia, ale też intensywnie korzysta z funkcji transferu libusb. Mam nadzieję, że ten przykład libusb był dla Ciebie inspiracją – wypróbujesz wersję demonstracyjną, pobawisz się samą biblioteką, a może nawet postanowisz przenieść inną powszechnie używaną bibliotekę do jednego z interfejsów API Fugu.