USB 애플리케이션을 웹에 포팅 파트 1: libusb

WebAssembly 및 Fugu API를 사용하여 외부 기기와 상호작용하는 코드를 웹으로 포팅하는 방법을 알아봅니다.

이전 게시물에서는 File System Access API, WebAssembly, Asyncify를 통해 파일 시스템 API를 사용하는 앱을 웹으로 포팅하는 방법을 보여드렸습니다. 이제 Fugu API를 WebAssembly와 통합하고 중요한 기능을 유지하면서 앱을 웹에 포팅하는 동일한 주제를 계속 설명하겠습니다.

C로 작성된 널리 사용되는 USB 라이브러리인 libusb를 WebAssembly (Emscripten을 통해), Asyncify 및 WebUSB로 포팅하여 USB 기기와 통신하는 앱을 웹으로 포팅하는 방법을 보여드리겠습니다.

가장 먼저 할 일: 데모

라이브러리를 포팅할 때 가장 중요한 일은 올바른 데모를 선택하는 것입니다. 올바른 데모를 선택하면 포팅된 라이브러리의 기능을 보여주어 다양한 방법으로 테스트하면서 동시에 시각적으로 매력적으로 보일 수 있습니다.

제가 선택한 아이디어는 DSLR 리모컨이었습니다. 특히 오픈소스 프로젝트인 gPhoto2는 다양한 디지털 카메라에 대한 지원을 리버스 엔지니어링하고 구현하기 위해 이 분야에 오랫동안 투자해 왔습니다. 여러 프로토콜을 지원하지만, 제가 가장 관심을 두었던 프로토콜은 libusb를 통해 실행되는 USB 지원이었습니다.

이 데모를 구축하는 단계를 두 부분으로 나누어 설명하겠습니다. 이 블로그 게시물에서는 libusb 자체를 포팅한 방법과 다른 인기 있는 라이브러리를 Fugu API로 포팅하는 방법에 대해 설명합니다. 두 번째 게시물에서는 gPhoto2의 포팅과 통합에 대해 자세히 알아보겠습니다.

결국에는 DSLR에서 라이브 피드를 미리 보고 USB를 통해 설정을 제어할 수 있는 작동하는 웹 애플리케이션을 갖게 되었습니다. 기술 세부정보를 읽기 전에 언제든지 라이브 또는 사전 녹화된 데모를 확인하세요.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Sony 카메라에 연결된 노트북에서 실행되는 데모입니다.

카메라 관련 특이사항에 관한 참고사항

동영상에서 설정을 변경하는 데 시간이 오래 걸린다는 사실을 눈치채셨나요? 발생할 수 있는 대부분의 다른 문제와 마찬가지로, 이 문제는 WebAssembly 또는 WebUSB의 성능이 아니라 gPhoto2가 데모를 위해 선택한 특정 카메라와 상호작용하는 방식으로 인해 발생합니다.

Sony a6600은 ISO, 조리개 또는 셔터 속도와 같은 값을 직접 설정하는 API를 노출하지 않으며, 지정된 단계 수만큼 값을 높이거나 낮추는 명령어만 제공합니다. 문제를 더 복잡하게 만들기 위해 실제로 지원되는 값의 목록도 반환하지 않습니다. 반환된 목록은 여러 Sony 카메라 모델에서 하드코딩된 것처럼 보입니다.

이러한 값 중 하나를 설정할 때 gPhoto2는 다음 방법 외 다른 선택 사항이 없습니다.

  1. 선택한 값의 방향으로 한 걸음 또는 몇 걸음 이동합니다.
  2. 카메라가 설정을 업데이트할 때까지 잠시 기다립니다.
  3. 카메라가 실제로 착륙한 값을 다시 읽습니다.
  4. 마지막 단계가 원하는 값 이상으로 건너뛰지 않았는지, 목록의 끝 또는 시작 부분으로 둘러싸지 않았는지 확인합니다.
  5. 반복

다소 시간이 걸릴 수 있지만, 값이 카메라에서 실제로 지원되면 지원 범위에 도달하고 지원되지 않는 경우 가장 가까운 지원 값에서 중지됩니다.

다른 카메라는 설정, 기본 API, 쿼크 세트가 다를 수 있습니다. gPhoto2는 오픈소스 프로젝트이며 모든 카메라 모델을 자동으로 또는 수동으로 테스트할 수 없으므로 자세한 문제 신고 및 PR이 항상 좋습니다. 하지만 먼저 공식 gPhoto2 클라이언트와 문제를 재현해야 합니다.

크로스 플랫폼 호환성 관련 중요 참고사항

불행히도, 윈도우즈에서는 "잘 알려진" 기기(예: DSLR 카메라)에는 WebUSB와 호환되지 않는 시스템 드라이버가 할당됩니다. Windows에서 데모를 사용해 보려면 Zadig와 같은 도구를 사용하여 연결된 DSLR의 드라이버를 WinUSB 또는 libusb로 재정의해야 합니다. 이 접근 방식은 저를 비롯한 많은 사용자에게 적합하지만, 사용에 따른 책임은 사용자에게 있습니다.

Linux에서는 배포판에 따라 다르지만 WebUSB를 통해 DSLR에 액세스하도록 허용하려면 맞춤 권한을 설정해야 할 수 있습니다.

macOS와 Android에서는 데모가 즉시 작동합니다. Android 휴대전화에서 시도해 보신다면 가로 모드로 전환하시기 바랍니다. 제가 반응형 모드로 만드는 데는 많은 노력이 들지 않았기 때문입니다. 좋습니다.

<ph type="x-smartling-placeholder">
</ph> Android 휴대전화가 USB-C 케이블을 통해 Canon 카메라에 연결되어 있습니다. <ph type="x-smartling-placeholder">
</ph> Android 휴대전화에서 실행되는 동일한 데모입니다. 사진: Surma

WebUSB의 크로스 플랫폼 사용에 관한 자세한 가이드는 '플랫폼별 고려사항'을 참고하세요. 섹션 참조

libusb에 새 백엔드 추가

이제 기술적인 세부사항으로 넘어가겠습니다. libusb와 유사한 shim API를 제공하고 (이전에 다른 개발자가 실행한 바 있음) 다른 애플리케이션을 이에 연결할 수는 있지만, 이 접근 방식은 오류가 발생하기 쉽고 추가 확장 또는 유지관리가 더 어렵습니다. 저는 향후 업스트림으로 다시 기여하여 libusb에 병합될 수 있는 방식으로 제대로 하고 싶었습니다.

다행히 libusb README에는 다음과 같이 나와 있습니다.

“libusb는 다른 운영체제로 포팅할 수 있는 방식으로 내부적으로 추상화되어 있습니다. 자세한 내용은 포팅 파일을 참조하세요.'

libusb는 공개 API가 '백엔드'와 분리되는 방식으로 구조화됩니다. 이러한 백엔드는 운영체제의 하위 수준 API를 통해 기기의 나열, 열기, 닫기 및 실제 통신을 담당합니다. libusb는 이미 Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku 및 Solaris 간의 차이점을 추상화하고 이러한 모든 플랫폼에서 작동합니다.

Emscripten+WebUSB '운영체제'를 위한 또 다른 백엔드를 추가해야 했습니다. 이러한 백엔드의 구현은 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

각 백엔드에는 일반 유형과 도우미가 있는 libusbi.h 헤더가 포함되어 있으며 usbi_os_backend 유형의 usbi_backend 변수를 노출해야 합니다. 예를 들어 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),
};

속성을 살펴보면 구조체에 백엔드 이름, 기능 집합, 함수 포인터 형식으로 된 다양한 하위 수준 USB 작업을 위한 핸들러, 마지막으로 비공개 기기/컨텍스트/전송 수준 데이터 저장을 위해 할당할 크기가 포함되어 있음을 알 수 있습니다.

비공개 데이터 필드는 최소한 모든 요소에 OS 핸들을 저장하는 데 유용합니다. 핸들이 없으면 주어진 작업이 어떤 항목에 적용되는지 알 수 없기 때문입니다. 웹 구현에서 OS 핸들은 기본 WebUSB JavaScript 객체입니다. Emscripten에 이를 표현하고 저장하는 자연스러운 방법은 Embind (Emscripten의 결합 시스템)의 일부로 제공되는 emscripten::val 클래스를 사용하는 것입니다.

폴더의 대부분의 백엔드는 C로 구현되지만 일부는 C++로 구현됩니다. Embind는 C++에서만 작동하므로 자동으로 선택되었고, 필수 구조와 비공개 데이터 필드에 관해 sizeof(val)를 사용하여 libusb/libusb/os/emscripten_webusb.cpp를 추가했습니다.

#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 객체를 기기 핸들로 저장

libusb는 비공개 데이터에 할당된 영역에 대한 즉시 사용 가능한 포인터를 제공합니다. 이러한 포인터를 val 인스턴스로 사용하기 위해 제자리에 구성하고 참조로 가져오고 값을 밖으로 이동하는 작은 도우미를 추가했습니다.

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

동기 C 컨텍스트의 비동기 웹 API

이제 libusb가 동기 작업을 예상하는 비동기 WebUSB API를 처리할 방법이 필요했습니다. 이를 위해 Asyncify, 구체적으로는 val::await()를 통한 Embind 통합을 사용할 수 있습니다.

또한 WebUSB 오류를 올바르게 처리하고 libusb 오류 코드로 변환하고 싶었지만 현재 Embind에는 C++ 측의 JavaScript 예외 또는 Promise 거부를 처리할 방법이 없습니다. JavaScript 측에서 거부를 포착하고 결과를 C++ 측에서 안전하게 파싱할 수 있는 { error, value } 객체로 변환하여 이 문제를 해결할 수 있습니다. EM_JS 매크로와 Emval.to{Handle, Value} API를 함께 사용하여 이 작업을 수행했습니다.

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

이제 WebUSB 작업에서 반환된 Promisepromise_result::await()를 사용하고 errorvalue 필드를 별도로 검사할 수 있습니다.

예를 들어 libusb_device_handle에서 USBDevice를 나타내는 val를 가져와 open() 메서드를 호출하고 결과를 기다리고 libusb 상태 코드로 오류 코드를 반환하는 방법은 다음과 같습니다.

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

기기 열거

물론 기기를 열려면 libusb가 사용 가능한 기기 목록을 가져와야 합니다. 백엔드는 get_device_list 핸들러를 통해 이 작업을 구현해야 합니다.

문제는 다른 플랫폼과 달리 보안상의 이유로 웹에서 연결된 모든 USB 기기를 열거할 방법이 없다는 점입니다. 대신 흐름이 두 부분으로 나뉩니다. 먼저 웹 애플리케이션이 navigator.usb.requestDevice()를 통해 특정 속성이 있는 기기를 요청하고 사용자는 노출하려는 기기를 수동으로 선택하거나 권한 메시지를 거부합니다. 그런 다음 navigator.usb.getDevices()를 통해 이미 승인되어 연결된 기기가 애플리케이션에 표시됩니다.

처음에는 get_device_list 핸들러 구현에서 requestDevice()를 직접 사용하려고 했습니다. 그러나 연결된 기기 목록과 함께 권한 메시지를 표시하는 것은 민감한 작업으로 간주되며 사용자 상호작용 (예: 페이지의 버튼 클릭)에 의해 트리거되어야 합니다. 그렇지 않으면 항상 거부된 프로미스가 반환됩니다. libusb 애플리케이션은 애플리케이션 시작 시 연결된 기기를 나열하려고 할 때가 많으므로 requestDevice()는 사용하지 않았습니다.

대신 최종 개발자에게 navigator.usb.requestDevice() 호출을 남기고 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;
}

대부분의 백엔드 코드는 위에 표시된 것과 비슷한 방식으로 valpromise_result를 사용합니다. 데이터 전송 처리 코드에는 몇 가지 흥미로운 꿀팁이 더 많이 있지만 이 도움말의 목적에 비추어 볼 때 이러한 구현 세부정보는 덜 중요합니다. 관심이 있다면 GitHub의 코드와 주석을 확인하세요.

웹에 이벤트 루프 포팅

libusb 포트에서 논의할 또 다른 부분은 이벤트 처리입니다. 이전 도움말에서 설명한 것처럼 C와 같은 시스템 언어로 된 대부분의 API는 동기식이며 이벤트 처리도 예외가 아닙니다. 일반적으로 '폴링'하는 무한 루프를 통해 구현됩니다. (일부 데이터를 사용할 수 있을 때까지 데이터를 읽으려고 시도하거나 실행을 차단)하고, 이러한 소스 중 하나 이상이 응답하면 이를 이벤트로 해당 핸들러에 전달합니다. 핸들러가 완료되면 컨트롤은 루프로 돌아가고 다른 폴에 대해 일시 중지합니다.

웹에서 이 접근 방식에는 몇 가지 문제가 있습니다.

첫째, WebUSB는 기본 기기의 원시 핸들을 노출하지 않고 노출할 수 없으므로 직접 폴링하는 것은 옵션이 아닙니다. 둘째, libusb는 다른 이벤트에도 eventfdpipe API를 사용하고 원시 기기 핸들이 없는 운영체제에서 전송을 처리하기 위해 사용합니다. 그러나 eventfd는 현재 Emscripten에서 지원되지 않으며 pipe는 지원되지만 현재 사양을 준수하지 않으며 이벤트를 기다릴 수 없습니다.

마지막으로 가장 큰 문제는 웹에 자체 이벤트 루프가 있다는 것입니다. 이 전역 이벤트 루프는 모든 외부 I/O 작업 (fetch(), 타이머, 이 경우 WebUSB 포함)에 사용되며 해당 작업이 완료될 때마다 이벤트 또는 Promise 핸들러를 호출합니다. 또 다른 중첩된 무한 이벤트 루프를 실행하면 브라우저의 이벤트 루프가 진행되지 않습니다. 즉, UI가 응답하지 않을 뿐만 아니라 코드가 기다리고 있는 바로 그 I/O 이벤트에 대한 알림을 받지 못합니다. 이렇게 하면 일반적으로 교착 상태가 발생하므로 데모에서 libusb를 사용하려고 할 때도 이 문제가 발생했습니다. 페이지가 멈췄습니다.

다른 차단 I/O와 마찬가지로, 이러한 이벤트 루프를 웹에 포팅하려면 개발자는 기본 스레드를 차단하지 않고 이러한 루프를 실행할 방법을 찾아야 합니다. 한 가지 방법은 별도의 스레드에서 I/O 이벤트를 처리하고 결과를 기본 스레드에 다시 전달하도록 애플리케이션을 리팩터링하는 것입니다. 다른 방법은 Asyncify를 사용하여 루프를 일시중지하고 비차단 방식으로 이벤트를 기다리는 것입니다.

libusb 또는 gPhoto2를 크게 변경하고 싶지는 않았고 이미 Promise 통합에 Asyncify를 사용했으므로 이 경로를 선택했습니다. poll()의 차단 변형을 시뮬레이션하기 위해 초기 개념 증명을 위해 아래와 같이 루프를 사용했습니다.

#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

기능은 다음과 같습니다.

  1. poll()를 호출하여 아직 백엔드에서 보고한 이벤트가 있는지 확인합니다. 하나라도 있으면 루프가 중지됩니다. 그러지 않으면 Emscripten의 poll() 구현이 즉시 0와 함께 반환됩니다.
  2. emscripten_sleep(0)을 호출합니다. 이 함수는 내부적으로 Asyncify 및 setTimeout()를 사용하며 기본 브라우저 이벤트 루프에 제어권을 다시 양도하는 데 사용됩니다. 이렇게 하면 브라우저가 WebUSB를 포함한 모든 사용자 상호작용 및 I/O 이벤트를 처리할 수 있습니다.
  3. 지정된 제한 시간이 아직 만료되었는지 확인하고 만료되지 않았으면 루프를 계속합니다.

댓글에서 언급했듯이 이 접근 방식은 아직 처리할 USB 이벤트가 없는 경우에도 (대부분) Asyncify를 사용하여 전체 호출 스택을 계속 저장 및 복원하고 최신 브라우저에서 setTimeout() 자체의 지속 시간이 4ms이기 때문에 최적의 방법이 아닙니다. 하지만 개념 증명에서 DSLR의 13~14FPS 라이브 스트림을 제작할 수 있을 만큼 충분히 작동했습니다.

나중에는 브라우저 이벤트 시스템을 활용하여 이 기능을 개선하기로 했습니다. 이 구현을 더욱 개선할 수 있는 방법은 여러 가지가 있지만, 지금은 맞춤 이벤트를 특정 libusb 데이터 구조와 연결하지 않고 전역 객체에서 직접 내보내도록 선택했습니다. 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);
  }
});

em_libusb_notify() 함수는 libusb가 데이터 전송 완료와 같은 이벤트를 보고하려고 할 때마다 사용됩니다.

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
}

한편 em_libusb_wait() 부분은 '절전 모드 해제'에 사용됩니다. em-libusb 이벤트가 수신되거나 제한 시간이 만료된 경우 절전 모드를 Asyncify에서 해제합니다.

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

절전 모드 및 wakeup이 크게 감소한 덕분에 이 메커니즘은 이전 emscripten_sleep() 기반 구현의 효율성 문제를 해결했으며 DSLR 데모 처리량을 13~14FPS에서 일관된 30FPS 이상으로 늘려 원활한 라이브 피드를 제공하기에 충분했습니다.

빌드 시스템 및 첫 번째 테스트

백엔드가 완료된 후 이를 Makefile.amconfigure.ac에 추가해야 했습니다. 여기서 유일하게 흥미로운 점은 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']"
  ;;

첫째, Unix 플랫폼의 실행 파일에는 일반적으로 파일 확장자가 없습니다. 그러나 Emscripten은 요청하는 확장 프로그램에 따라 다른 출력을 생성합니다. AC_SUBST(EXEEXT, …)를 사용하여 실행 가능한 확장자를 .html로 변경하여 패키지 내의 모든 실행 파일(테스트 및 예시)이 JavaScript 및 WebAssembly 로드 및 인스턴스화를 처리하는 Emscripten의 기본 셸을 사용하는 HTML이 되도록 합니다.

둘째, Embind와 Asyncify를 사용하고 있으므로 이러한 기능 (--bind -s ASYNCIFY)을 사용 설정하고 링커 매개변수를 통해 동적 메모리 증가 (-s ALLOW_MEMORY_GROWTH)를 허용해야 합니다. 불행히도 라이브러리가 이러한 플래그를 링커에 보고할 수 있는 방법은 없으므로, 이 libusb 포트를 사용하는 모든 애플리케이션은 동일한 링커 플래그를 빌드 구성에도 추가해야 합니다.

마지막으로, 앞서 언급했듯이 WebUSB를 사용하려면 사용자 동작을 통해 기기 열거를 실행해야 합니다. libusb 예제와 테스트는 시작 시 기기를 열거할 수 있고 변경 없이 오류가 발생하면서 실패한다고 가정합니다. 대신 자동 실행 (-s INVOKE_RUN=0)을 사용 중지하고 수동 callMain() 메서드 (-s EXPORTED_RUNTIME_METHODS=...)를 노출해야 했습니다.

이 모든 작업이 완료되면 정적 웹 서버를 사용하여 생성된 파일을 제공하고, WebUSB를 초기화하고, DevTools의 도움을 받아 이러한 HTML 실행 파일을 수동으로 실행할 수 있었습니다.

로컬에서 제공되는 `testlibusb` 페이지에 DevTools가 열려 있는 Chrome 창을 보여주는 스크린샷 DevTools 콘솔에서 `navigator.usb.requestDevice({ filters: [] })`를 평가하여 권한 메시지가 트리거되었으며 현재 사용자에게 페이지와 공유해야 하는 USB 기기를 선택하라는 메시지가 표시되고 있습니다. 현재 ILCE-6600 (Sony 카메라)이 선택되어 있습니다.

DevTools가 아직 열려 있는 다음 단계의 스크린샷 기기가 선택된 후 Console은 새 표현식 `Module.callMain([&#39;-v&#39;])`를 평가하여 `testlibusb` 앱을 상세 모드로 실행했습니다. 출력에는 제조업체 Sony, 제품 ILCE-6600, 일련번호, 구성 등 이전에 연결된 USB 카메라에 관한 다양한 세부정보가 표시됩니다.

별것 아닌 것처럼 보이지만 라이브러리를 새 플랫폼으로 포팅할 때 처음으로 유효한 출력을 생성하는 단계에 도달하는 것은 매우 흥미진진한 일입니다.

포트 사용

에서 언급했듯이 포트는 현재 애플리케이션의 연결 단계에서 사용 설정해야 하는 몇 가지 Emscripten 기능에 종속됩니다. 자신의 애플리케이션에서 이 libusb 포트를 사용하려면 다음과 같이 해야 합니다.

  1. 최신 libusb를 빌드의 일부로 보관 파일로 다운로드하거나 프로젝트에 git 하위 모듈로 추가합니다.
  2. libusb 폴더에서 autoreconf -fiv를 실행합니다.
  3. emconfigure ./configure –host=wasm32 –prefix=/some/installation/path를 실행하여 크로스 컴파일을 위해 프로젝트를 초기화하고 빌드된 아티팩트를 배치할 경로를 설정합니다.
  4. emmake make install을 실행합니다.
  5. 애플리케이션 또는 상위 수준 라이브러리가 이전에 선택한 경로 아래에서 libusb를 검색하도록 지정합니다.
  6. 애플리케이션의 링크 인수에 --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH 플래그를 추가합니다.

현재 라이브러리에는 몇 가지 제한사항이 있습니다.

  • 이전 취소는 지원되지 않습니다. 이는 WebUSB의 제한사항이며, 이는 libusb 자체에 크로스 플랫폼 전송 취소가 없기 때문입니다.
  • 등시 전송은 지원되지 않습니다. 기존 전송 모드를 예시로 구현하여 추가하는 것은 어렵지 않을 것입니다. 하지만 다소 드문 모드이기도 하고 테스트할 기기가 없었기 때문에 지금은 지원되지 않는 상태로 두었습니다. 이러한 기기를 보유하고 있으며 라이브러리에 기여하고 싶다면 PR을 이용해 주시기 바랍니다.
  • 앞서 언급한 크로스 플랫폼 제한사항은 이러한 제한은 운영 체제에 의해 부과되므로 사용자에게 드라이버 또는 권한을 재정의하도록 요청하는 것 외에는 할 수 있는 일이 별로 없습니다. 그러나 HID 또는 직렬 기기를 포팅하는 경우 libusb 예제를 따라 다른 라이브러리를 다른 Fugu API로 포팅할 수 있습니다. 예를 들어 C 라이브러리 hidapiWebHID로 포팅하고 낮은 수준의 USB 액세스와 관련된 이러한 문제를 완전히 피할 수 있습니다.

결론

이 게시물에서는 Emscripten, Asyncify 및 Fugu API를 사용하여 libusb와 같은 하위 수준 라이브러리까지도 몇 가지 통합 방법을 통해 웹으로 포팅할 수 있는 방법을 보여드렸습니다.

이처럼 필수적이고 널리 사용되는 하위 레벨 라이브러리를 포팅하면 특히 보상을 받게 됩니다. 왜냐하면 상위 레벨 라이브러리나 전체 애플리케이션을 웹으로 가져올 수 있기 때문입니다. 이에 따라 이전에는 한두 개의 플랫폼 사용자만 사용할 수 있었던 사용 환경을 모든 종류의 기기와 운영체제에서 사용할 수 있게 되어 링크 클릭 한 번으로 이러한 환경을 이용할 수 있게 됩니다.

다음 게시물에서는 기기 정보를 가져올 뿐만 아니라 libusb의 전송 기능도 광범위하게 사용하는 웹 gPhoto2 데모를 빌드하는 단계를 살펴보겠습니다. libusb 예제가 영감을 얻으셨기를 바라며 데모를 사용해 보거나, 라이브러리 자체를 사용해 보거나, 널리 사용되는 다른 라이브러리도 Fugu API 중 하나로 포팅해 보시기 바랍니다.