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

Dowiedz się, jak kod, który wchodzi w interakcje z urządzeniami zewnętrznymi, można przenieść do sieci za pomocą 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 chcę kontynuować temat integracji interfejsów Fugu API z WebAssembly oraz przenoszenia aplikacji do sieci bez utraty ważnych funkcji.

Pokażę, jak aplikacje komunikujące się z urządzeniami USB można przenosić do sieci, przenosząc libusb – popularną bibliotekę USB napisaną w języku C – do 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.

W końcu udało mi się stworzyć działającą aplikację internetową, która wyświetla podgląd na żywo z lustra odbijającego światło i pozwala kontrolować ustawienia za pomocą 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ą

Możesz zauważyć, że zmiana ustawień 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 faktycznie obsługiwanych wartości. Wygląda na to, że zwracana lista jest zakodowana w wielu modelach aparatów Sony.

Podczas ustawiania jednej z tych wartości gPhoto2 nie ma innego wyjścia, jak tylko:

  1. Zmień wartość o jeden lub kilka kroków w kierunku wybranej wartości.
  2. Zaczekaj chwilę, aż aparat zaktualizuje ustawienia.
  3. Odczytuje wartość, na którą natrafiła kamera.
  4. Sprawdź, czy ostatni krok nie pomijał żądanej wartości ani nie objął jej całościowo.
  5. Powtórz.

Może to zająć trochę czasu, ale jeśli wartość jest obsługiwana przez kamerę, to się uda, a jeśli nie, to zatrzyma się na najbliższej obsługiwanej wartości.

Inne kamery będą prawdopodobnie mieć inne zestawy ustawień, interfejsów API i nietypowych funkcji. 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 z zadowoleniem przyjmujemy szczegółowe zgłoszenia problemów i materiały PR (ale najpierw sprawdź, czy problemy można odtworzyć za pomocą oficjalnego klienta 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 Windows, musisz użyć narzędzia takiego jak Zadig, aby zastąpić sterownik podłączonego aparatu cyfrowego przez 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 demo powinno działać od razu po zainstalowaniu. 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 za pomocą kabla USB-C.
To samo demo na telefonie z Androidem. Zdjęcie: Surma.

Pełniejszy przewodnik po używaniu WebUSB na różnych platformach znajdziesz w sekcji „Uwzględnienie specyfikicznego dla platformy” w artykule „Tworzenie urządzenia do obsługi WebUSB”.

Dodawanie nowego backendu do libusb

Teraz szczegóły techniczne. 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łem, aby wszystko było zrobione prawidłowo, tak aby można było w przyszłości wprowadzić zmiany w głównym repozytorium i przekazać je do libusb.

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 komunikację z nimi za pomocą interfejsów API niskiego poziomu 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),
};

Po przejrzeniu właściwości widać, że struktura zawiera nazwę backendu, zestaw jego możliwości, obsługi różnych operacji na poziomie interfejsu USB w postaci wskaźników funkcji oraz rozmiary do przydzielenia na potrzeby 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 OS to podstawowe 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 kilka z nich jest 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++. Użyłem do tego kombinacji 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żywać funkcji promise_result::await() w przypadku dowolnego Promise zwracanego przez operacje WebUSB i oddzielnie sprawdzać pola errorvalue.

Na przykład pobieranie z libusb_device_handle obiektu val reprezentującego obiekt USBDevice, wywołanie metody open(), oczekiwanie na wynik i zwracanie kodu błędu jako 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.

Zamiast tego musiałem pozostawić wywołanie navigator.usb.requestDevice() deweloperowi końcowemu i wyświetlać tylko już zatwierdzone urządzenia z 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 te 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

Kolejnym elementem 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, 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 głosowaniu.

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

Po pierwsze, WebUSB nie udostępnia i nie może udostępniać danych o nieprzetworzonych uchwytach urządzeń, więc bezpośrednie ich odczytywanie nie jest możliwe. Po drugie, libusb używa interfejsów API eventfdpipe do obsługi innych zdarzeń oraz do obsługi przesyłania w systemach operacyjnych bez obsługi interfejsu raw device handle, ale eventfd nie jest obecnie obsługiwany w Emscripten, a pipe, mimo że jest obsługiwany, nie jest zgodny 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 w celu obsługi zdarzeń we/wy w osobnym wątku i przekazywanie wyników z powrotem do wątku głównego. Drugim sposobem jest użycie Asyncify do wstrzymania pętli i oczekiwania na zdarzenia w nieblokowany sposób.

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

Co to jest:

  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 natychmiast zwróci wartość 0.
  2. Połączenia 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 limit czasu nie wygasł. 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 testów koncepcyjnych 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. Istnieje kilka sposobów na ulepszanie tej implementacji, ale na razie zdecydowałem się emitować zdarzenia niestandardowe bezpośrednio w obiekcie globalnym, bez kojarzenia ich z konkretną strukturą danych libusb. Zrobiłem to za pomocą 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, gdy zostanie odebrane zdarzenie em-libusb lub upłynie czas 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 pierwszego testu

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, …), aby zmienić rozszerzenie pliku wykonywalnego na .html, dzięki czemu każdy plik wykonywalny w pakiecie (testy i przykłady) staje się plikiem HTML z domyślną powłoką Emscripten, która zajmuje się wczytywaniem i instancjonowaniem JavaScriptu i 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 można zliczać urządzenia podczas uruchamiania, a w przeciwnym razie nie uda się tego zrobić. 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ą statycznego serwera WWW, inicjować WebUSB i uruchamiać pliki wykonywalne HTML ręcznie za pomocą DevTools.

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

Zrzut ekranu z kolejną czynnością, w której nadal otwarte 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.

Może nie wygląda to na wiele, ale podczas przenoszenia bibliotek na nową platformę osiągnięcie etapu, na którym po raz pierwszy wygeneruje ona prawidłowy wynik, jest bardzo ekscytują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ć tej wersji biblioteki libusb w swojej aplikacji, wykonaj te czynności:

  1. Pobierz najnowszą wersję libusb jako archiwum w ramach kompilacji lub dodaj ją jako podmoduł git w 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ę, na której mają się znajdować skompilowane artefakty.
  4. Uruchom emmake make install.
  5. Wskazywanie aplikacji lub biblioteki wyższego poziomu w celu wyszukiwania 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 na różnych platformach w samej bibliotece libusb.
  • Brak obsługi przesyłania równoległego. Dodanie go nie powinno być trudne, jeśli za przykład weźmiemy i zaimplementujemy istniejące tryby przesyłania. Jest to jednak tryb rzadko używany i nie mam urządzeń, na których mógłbym go przetestować, więc na razie pozostawiam go jako nieobsługiwany. Jeśli masz takie urządzenia i chcesz dodać je do biblioteki, chętnie je przyjmiemy.
  • Wspomnienia o ograniczeniach dotyczących różnych 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 przykład libusb okazał się inspirujący i że skorzystasz z demo, pobawisz się samą biblioteką, a może nawet przeniesiesz inną popularną bibliotekę do jednego z interfejsów Fugu.