انتقال برنامه‌های USB به وب. بخش ۱: libusb

با نحوه انتقال کدی که با دستگاه‌های خارجی تعامل دارد به وب بااستفاده از WebAssembly و میاناهای برنامه‌سازی کاربردی Fugu آشنا شوید.

در پست قبلی، نشان دادم که چگونه برنامه‌هایی را که از میاناهای برنامه‌سازی کاربردی سیستم فایل استفاده می‌کنند بااستفاده از File System Access API،‏ WebAssembly، و Asyncify به وب منتقل کنید. اکنون می‌خواهم همان موضوع ادغام Fugu APIs با WebAssembly و انتقال برنامه‌ها به وب را بدون ازدست دادن ویژگی‌های مهم ادامه دهم.

نشان خواهم داد که چگونه برنامه‌هایی که با دستگاه‌های USB ارتباط برقرار می‌کنند می‌توانند با انتقال libusb (یک کتابخانه USB محبوب که به زبان C نوشته شده است) به WebAssembly (ازطریق Emscripten)،‏ Asyncify، و WebUSB به وب منتقل شوند.

اولین چیز: نسخه نمایشی

مهم‌ترین کاری که هنگام انتقال کتابخانه باید انجام دهید، انتخاب نسخه نمایشی مناسب است—چیزی که قابلیت‌های کتابخانه منتقل‌شده را به‌نمایش بگذارد، به شما امکان دهد آن را به روش‌های مختلف آزمایش کنید، و درعین‌حال ازنظر بصری جذاب باشد.

ایده‌ای که انتخاب کردم کنترل از راه دور DSLR بود. به‌ویژه، پروژه متن‌باز gPhoto2 به‌اندازه کافی در این فضا بوده است تا بتواند پشتیبانی از طیف گسترده‌ای از دوربین‌های دیجیتال را مهندسی معکوس و پیاده‌سازی کند. این برنامه از چندین پروتکل پشتیبانی می‌کند، اما پروتکلی که من بیشتر به آن علاقه‌مند بودم پشتیبانی از USB بود که ازطریق libusb انجام می‌شود.

مراحل ساخت این نسخه نمایشی را در دو بخش شرح خواهم داد. در این پست وبلاگ، توضیح خواهم داد که چگونه libusb را منتقل کردم و چه ترفندهایی ممکن است برای انتقال سایر کتابخانه‌های محبوب به میاناهای برنامه‌سازی کاربردی Fugu لازم باشد. در پست دوم، جزئیات مربوط به انتقال و ادغام gPhoto2 را شرح خواهم داد.

درنهایت، یک برنامه وب کاربردی دریافت کردم که فید زنده را از DSLR پیش‌نمایش می‌کند و می‌تواند تنظیمات آن را ازطریق USB کنترل کند. پیش‌از خواندن جزئیات فنی، می‌توانید نسخه نمایشی زنده یا ازپیش ضبط‌شده را بررسی کنید:

نسخه نمایشی درحال اجرا در رایانه کیفی متصل به دوربین Sony.

نکته‌ای درباره ویژگی‌های خاص دوربین

شاید متوجه شده باشید که تغییر تنظیمات در ویدیو کمی طول می‌کشد. مانند اکثر مشکلات دیگری که ممکن است ببینید، این مشکل ناشی از عملکرد WebAssembly یا WebUSB نیست، بلکه ناشی از نحوه تعامل gPhoto2 با دوربین خاصی است که برای نسخه نمایشی انتخاب شده است.

Sony a6600 میانای برنامه‌سازی کاربردی‌ای برای تنظیم مستقیم مقادیری مثل ISO، دیافراگم، یا سرعت شاتر ارائه نمی‌دهد و درعوض فقط فرمان‌هایی برای افزایش یا کاهش آن‌ها با تعداد مراحل مشخص‌شده ارائه می‌دهد. برای پیچیده‌تر کردن موضوع، این ویژگی فهرستی از مقادیر واقعاً پشتیبانی‌شده را نیز برنمی‌گرداند—به‌نظر می‌رسد فهرست برگردانده‌شده در بسیاری از مدل‌های دوربین Sony کدگذاری شده است.

هنگام تنظیم یکی از این مقادیر، gPhoto2 هیچ انتخاب دیگری ندارد جز اینکه:

  1. یک (یا چند) قدم در جهت ارزش انتخاب‌شده بردارید.
  2. کمی صبر کنید تا دوربین تنظیمات را به‌روز کند.
  3. مقدار واقعی را که دوربین روی آن قرار گرفته است بخوانید.
  4. بررسی کنید که آخرین مرحله از مقدار موردنظر نگذشته باشد یا به ابتدا یا انتهای فهرست نرفته باشد.
  5. تکرار.

ممکن است کمی طول بکشد، اما اگر مقدار واقعاً توسط دوربین پشتیبانی شود، به آن می‌رسد و اگر پشتیبانی نشود، روی نزدیک‌ترین مقدار پشتیبانی‌شده متوقف می‌شود.

دوربین‌های دیگر احتمالاً مجموعه‌های متفاوتی از تنظیمات، میاناهای برنامه‌سازی کاربردی زیربنایی، و ویژگی‌های خاص خواهند داشت. به‌خاطر داشته باشید که gPhoto2 پروژه‌ای متن‌باز است و آزمایش خودکار یا دستی همه مدل‌های دوربین موجود در بازار به‌سادگی امکان‌پذیر نیست، بنابراین گزارش‌های دقیق مشکل و PR همیشه مورد استقبال قرار می‌گیرند (اما مطمئن شوید که ابتدا مشکلات را با کلاینت رسمی gPhoto2 بازتولید کنید).

نکات مهم درباره سازگاری چندسکویی

متأسفانه در Windows، به هر دستگاه «شناخته‌شده‌ای»، ازجمله دوربین‌های DSLR، یک درایور سیستم اختصاص داده می‌شود که با WebUSB سازگار نیست. اگر می‌خواهید نسخه نمایشی را در Windows امتحان کنید، باید از ابزاری مانند Zadig استفاده کنید تا درایور DSLR متصل‌شده را به WinUSB یا libusb لغو کنید. این رویکرد برای من و بسیاری از کاربران دیگر به‌خوبی کار می‌کند، اما باید با مسئولیت خودتان از آن استفاده کنید.

در Linux، احتمالاً باید اجازه‌های سفارشی تنظیم کنید تا دسترسی به DSLR شما ازطریق WebUSB مجاز شود، هرچند این موضوع به توزیع شما بستگی دارد.

در macOS و Android، نسخه نمایشی باید بدون نیاز به پیکربندی کار کند. اگر آن را در تلفن Android امتحان می‌کنید، حتماً به حالت افقی بروید چون من تلاش زیادی برای واکنش‌گرا کردن آن نکرده‌ام (از درخواست‌های ادغام استقبال می‌شود!):

تلفن Android ازطریق کابل USB-C به دوربین Canon متصل شده است.
همان نسخه نمایشی درحال اجرا در تلفن Android. عکس از Surma.

برای راهنمایی عمیق‌تر درباره استفاده چندسکویی از WebUSB، به بخش «ملاحظات خاص پلاتفرم» در «ساخت دستگاه برای WebUSB» مراجعه کنید.

افزودن پسای جدید به libusb

اکنون به جزئیات فنی می‌پردازیم. درحالی‌که امکان ارائه یک API شبیه‌ساز مشابه libusb وجود دارد (این کار قبلاً توسط دیگران انجام شده است) و می‌توان برنامه‌های دیگر را به آن پیوند داد، این رویکرد مستعد خطا است و هرگونه توسعه یا نگهداری بیشتر را دشوارتر می‌کند. می‌خواستم کارها را به‌درستی انجام دهم، به‌گونه‌ای که بتواند به‌طور بالقوه به بالادست کمک کند و در آینده با libusb ادغام شود.

خوشبختانه، libusb README می‌گوید:

«libusb به‌صورت داخلی به‌گونه‌ای انتزاعی شده است که امیدواریم بتوان آن را به سیستم‌عامل‌های دیگر منتقل کرد. لطفاً برای اطلاعات بیشتر به فایل PORTING مراجعه کنید.»

libusb به‌گونه‌ای ساختار یافته است که میانای برنامه‌سازی کاربردی عمومی از «پشت‌افزارها» جدا است. این پسین‌ها مسئول فهرست کردن، باز کردن، بستن، و درواقع برقراری ارتباط با دستگاه‌ها ازطریق میاناهای برنامه‌سازی کاربردی سطح پایین سیستم‌عامل هستند. به این ترتیب 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_backend از نوع usbi_os_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 سطح پایین به‌شکل اشاره‌گرهای تابع، و درنهایت، اندازه‌هایی برای اختصاص دادن به ذخیره داده‌های خصوصی سطح دستگاه/زمینه/انتقال است.

فیلدهای داده خصوصی حداقل برای ذخیره کردن دسته‌های سیستم‌عامل برای همه این موارد مفید هستند، زیرا بدون دسته‌ها نمی‌دانیم که هر عملیات معین برای کدام مورد اعمال می‌شود. در پیاده‌سازی وب، دستگیره‌های سیستم‌عامل همان اشیای جاوا اسکریپت WebUSB زیربنایی خواهند بود. روش طبیعی برای نمایش و ذخیره آن‌ها در Emscripten ازطریق کلاس emscripten::val است که به‌عنوان بخشی از Embind (سیستم اتصال Emscripten) ارائه می‌شود.

بیشتر پس‌کارهای موجود در پوشه در C پیاده‌سازی شده‌اند، اما چند مورد در ++C پیاده‌سازی شده‌اند. Embind فقط با ++C کار می‌کند، بنابراین انتخاب برای من انجام شد و من libusb/libusb/os/emscripten_webusb.cpp را با ساختار موردنیاز و با sizeof(val) برای فیلدهای داده خصوصی اضافه کردم:

#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 همگام

اکنون به روشی برای مدیریت میاناهای برنامه‌سازی کاربردی WebUSB ناهمگام نیاز بود که در آن libusb انتظار عملیات همگام را دارد. برای این کار، می‌توانم از Asyncify یا به‌طور خاص‌تر، از ادغام Embind آن ازطریق val::await() استفاده کنم.

همچنین می‌خواستم خطاهای WebUSB را به‌درستی مدیریت کنم و آن‌ها را به کدهای خطای libusb تبدیل کنم، اما Embind درحال‌حاضر هیچ راهی برای مدیریت استثناهای JavaScript یا رد کردن Promise ازطرف C++ ندارد. این مشکل را می‌توان با گرفتن رد در سمت جاوا اسکریپت و تبدیل نتیجه به یک شی { error, value } که اکنون می‌توان آن را با خیال راحت از سمت C++ تجزیه کرد، حل کرد. من این کار را با ترکیبی از ماکرو EM_JS و میاناهای برنامه‌سازی کاربردی 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()};
  }
};

اکنون می‌توانم از promise_result::await() در هر Promise برگردانده‌شده از عملیات WebUSB استفاده کنم و فیلدهای error و value آن را به‌طور جداگانه بررسی کنم.

برای مثال، بازیابی val که نشان‌دهنده USBDevice از libusb_device_handle است، فراخوانی روش 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() فهرست می‌کند.

ابتدا سعی کردم از requestDevice() مستقیماً در پیاده‌سازی گرداننده get_device_list استفاده کنم. بااین‌حال، نمایش پیام‌واره اجازه با فهرستی از دستگاه‌های متصل‌شده به‌عنوان عملیاتی حساس درنظر گرفته می‌شود و باید با تعامل کاربر (مانند کلیک کردن روی دکمه در صفحه) راه‌اندازی شود، درغیراین‌صورت همیشه یک وعده ردشده را برمی‌گرداند. برنامه‌های 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;
}

بیشتر کد پسین از val و promise_result به روشی مشابه آنچه در بالا نشان داده شده است استفاده می‌کند. چند ترفند جالب دیگر در کد مدیریت انتقال داده وجود دارد، اما این جزئیات پیاده‌سازی برای اهداف این مقاله اهمیت کمتری دارند. اگر علاقه‌مندید، حتماً کد و نظرات را در Github بررسی کنید.

انتقال حلقه‌های رویداد به وب

بخش دیگری از درگاه libusb که می‌خواهم درباره آن صحبت کنم، مدیریت رویداد است. همان‌طور که در مقاله قبلی توضیح داده شد، اکثر میاناهای برنامه‌سازی کاربردی در زبان‌های سیستم مانند C همگام هستند و مدیریت رویداد نیز از این قاعده مستثنی نیست. این الگو معمولاً ازطریق یک حلقه بی‌نهایت پیاده‌سازی می‌شود که از مجموعه‌ای از منابع ورودی/خروجی خارجی «نظرسنجی» می‌کند (تلاش می‌کند داده‌ها را بخواند یا اجرای برنامه را تا زمانی که داده‌ای دردسترس قرار گیرد مسدود می‌کند)، و وقتی حداقل یکی از آن‌ها پاسخ می‌دهد، آن را به‌عنوان یک رویداد به گرداننده مربوطه ارسال می‌کند. وقتی کار گرداننده تمام شد، کنترل به حلقه برمی‌گردد و برای نظرسنجی دیگری موقتاً متوقف می‌شود.

این رویکرد در وب چند مشکل دارد.

اول اینکه WebUSB دسته‌های خام دستگاه‌های زیربنایی را نشان نمی‌دهد و نمی‌تواند نشان دهد، بنابراین نظرسنجی مستقیم از آن‌ها گزینه‌ای نیست. دوم، libusb از میاناهای برنامه‌سازی کاربردی eventfd و pipe برای رویدادهای دیگر و همچنین برای مدیریت انتقال‌ها در سیستم‌عامل‌های بدون دستگیره دستگاه خام استفاده می‌کند، اما eventfd درحال‌حاضر در Emscripten پشتیبانی نمی‌شود و pipe، با اینکه پشتیبانی می‌شود، درحال‌حاضر با مشخصات مطابقت ندارد و نمی‌تواند منتظر رویدادها بماند.

درنهایت، بزرگ‌ترین مشکل این است که وب حلقه رویداد خودش را دارد. این حلقه رویداد سراسری برای هرگونه عملیات ورودی/خروجی خارجی (ازجمله fetch()، زمان‌سنج‌ها، یا در این مورد، WebUSB) استفاده می‌شود و هرگاه عملیات مربوطه به‌پایان برسد، رویداد یا گرداننده‌های Promise را فراخوانی می‌کند. اجرای یک حلقه رویداد تودرتو و بی‌نهایت دیگر، حلقه رویداد مرورگر را از پیشرفت بازمی‌دارد، که این یعنی نه تنها واسط کاربر غیرپاسخگو می‌شود، بلکه کد هرگز برای همان رویدادهای ورودی/خروجی که منتظرشان است اعلان دریافت نخواهد کرد. این معمولاً منجر به بن‌بست می‌شود و این همان اتفاقی است که وقتی سعی کردم از libusb در نسخه نمایشی استفاده کنم نیز رخ داد. صفحه متوقف شد.

مانند سایر ورودی/خروجی‌های مسدودکننده، برای انتقال چنین حلقه‌های رویدادی به وب، توسعه‌دهندگان باید راهی برای اجرای این حلقه‌ها بدون مسدود کردن رشته اصلی پیدا کنند. یک راه این است که برنامه را بازسازی کنید تا رویدادهای ورودی/خروجی را در رشته‌ای جداگانه مدیریت کند و نتایج را به رشته اصلی برگرداند. روش دیگر استفاده از Asyncify برای توقف موقت حلقه و انتظار برای رویدادها به‌صورت غیرمسدودکننده است.

نمی‌خواستم تغییرات قابل‌توجهی در libusb یا gPhoto2 ایجاد کنم و قبلاً از Asyncify برای ادغام Promise استفاده کرده بودم، بنابراین این مسیری است که انتخاب کرده‌ام. برای شبیه‌سازی نوع مسدودکننده 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، را مدیریت کند.
  3. بررسی کنید که آیا مهلت زمانی مشخص‌شده منقضی شده است یا نه، و اگر منقضی نشده است، حلقه را ادامه دهید.

همان‌طور که در نظر ذکر شده است، این رویکرد بهینه نبود زیرا کل پشته فراخوانی را با Asyncify ذخیره و بازیابی می‌کرد، حتی زمانی که هنوز هیچ رویداد USB برای رسیدگی وجود نداشت (که بیشتر اوقات این‌طور است)، و همچنین به این دلیل که setTimeout() خودش در مرورگرهای نو حداقل مدت‌زمان ۴ میلی‌ثانیه دارد. بااین‌حال، آن‌قدر خوب کار می‌کرد که در اثبات مفهوم، جاری‌سازی زنده با ۱۳ تا ۱۴ فریم در ثانیه از DSLR تولید کند.

بعداً تصمیم گرفتم با بهره‌گیری از سیستم رویداد مرورگر آن را بهبود دهم. چندین روش وجود دارد که می‌توان این پیاده‌سازی را بیشتر بهبود بخشید، اما درحال‌حاضر من انتخاب کرده‌ام که رویدادهای سفارشی را مستقیماً روی شیء سراسری منتشر کنم، بدون اینکه آن‌ها را با ساختار داده 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() برای «بیدار شدن» از خواب Asyncify استفاده می‌شود، چه زمانی که رویداد em-libusb دریافت شود یا زمان وقفه منقضی شده باشد:

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

به‌دلیل کاهش قابل‌توجه در حالت‌های خواب و بیداری، این سازوکار مشکلات کارایی پیاده‌سازی قبلی مبتنی بر emscripten_sleep() را برطرف کرد و توان عملیاتی نمایشی DSLR را از ۱۳-۱۴ فریم در ثانیه به بیش‌از ۳۰ فریم در ثانیه ثابت افزایش داد که برای یک فید زنده روان کافی است.

سیستم ساخت و اولین آزمایش

بعداز اینکه کار باطن تمام شد، باید آن را به Makefile.am و configure.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 استفاده می‌کنم تا هر فایل اجرایی در یک بسته (آزمایش‌ها و نمونه‌ها) به یک HTML با پوسته پیش‌فرض Emscripten تبدیل شود که بارگیری و نمونه‌سازی جاوا اسکریپت و WebAssembly را انجام می‌دهد.

دوم، چون از Embind و Asyncify استفاده می‌کنم، باید این ویژگی‌ها (--bind -s ASYNCIFY) را فعال کنم و همچنین ازطریق پارامترهای پیونددهنده اجازه رشد حافظه پویا (-s ALLOW_MEMORY_GROWTH) را بدهم. متأسفانه، هیچ راهی برای کتابخانه وجود ندارد که این پرچم‌ها را به پیونددهنده گزارش دهد، بنابراین هر برنامه‌ای که از این درگاه libusb استفاده می‌کند باید همان پرچم‌های پیونددهنده را به پیکربندی ساخت خود نیز اضافه کند.

درنهایت، همان‌طور که قبلاً ذکر شد، WebUSB نیاز دارد که شناسایی دستگاه ازطریق اشاره کاربر انجام شود. نمونه‌ها و آزمایش‌های libusb فرض می‌کنند که می‌توانند دستگاه‌ها را در زمان راه‌اندازی شناسایی کنند و بدون تغییر با خطا مواجه می‌شوند. درعوض، مجبور شدم اجرای خودکار (-s INVOKE_RUN=0) را غیرفعال کنم و روش دستی callMain() (-s EXPORTED_RUNTIME_METHODS=...) را نشان دهم.

وقتی همه این کارها انجام شد، می‌توانستم فایل‌های تولیدشده را با سرور وب ایستا ارائه دهم، WebUSB را مقداردهی اولیه کنم، و آن فایل‌های اجرایی HTML را به‌صورت دستی با کمک DevTools اجرا کنم.

نماگرفتی که پنجره Chrome را با DevTools بازشده در صفحه `testlibusb` ارائه‌شده به‌صورت محلی نشان می‌دهد. کنسول DevTools درحال ارزیابی `navigator.usb.requestDevice({ filters: [] })` است که باعث فعال شدن پیام‌واره اجازه شده است و درحال‌حاضر از کاربر می‌خواهد دستگاه USB را که باید با صفحه هم‌رسانی شود انتخاب کند. ILCE-6600 (دوربین Sony) درحال‌حاضر انتخاب شده است.

نماگرفت مرحله بعدی، درحالی‌که DevTools هنوز باز است. پس‌از انتخاب دستگاه، «کنسول» عبارت جدید `Module.callMain(['-v'])` را ارزیابی کرده است که برنامه `testlibusb` را در حالت پرجزئیات اجرا کرده است. خروجی اطلاعات دقیق مختلفی را درباره دوربین USB متصل‌شده قبلی نشان می‌دهد: سازنده Sony، محصول ILCE-6600، شماره سریال، پیکربندی، و غیره.

شاید چیز خاصی به‌نظر نرسد، اما هنگام انتقال کتابخانه‌ها به پلاتفرم جدید، رسیدن به مرحله‌ای که برای اولین‌بار خروجی معتبری تولید می‌کند بسیار هیجان‌انگیز است!

استفاده از درگاه

همان‌طور که در بالا ذکر شد، درگاه به چند ویژگی Emscripten بستگی دارد که درحال‌حاضر باید در مرحله پیوند دادن برنامه فعال شوند. اگر می‌خواهید از این درگاه libusb در برنامه خودتان استفاده کنید، باید کارهای زیر را انجام دهید:

  1. جدیدترین libusb را به‌عنوان بایگانی به‌عنوان بخشی از ساخت خود بارگیری کنید یا آن را به‌عنوان زیرماژول git در پروژه خود اضافه کنید.
  2. ‫autoreconf -fiv را در پوشه libusb اجرا کنید.
  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 ناشی می‌شود.
  • از انتقال هم‌زمان پشتیبانی نمی‌شود. با دنبال کردن پیاده‌سازی حالت‌های انتقال موجود به‌عنوان نمونه، افزودن آن نباید سخت باشد، اما این حالت تا حدودی نادر است و من هیچ دستگاهی برای آزمایش آن نداشتم، بنابراین درحال‌حاضر آن را به‌عنوان پشتیبانی‌نشده رها کردم. اگر چنین دستگاه‌هایی دارید و می‌خواهید در کتابخانه مشارکت کنید، از درخواست‌های Pull استقبال می‌کنیم!
  • محدودیت‌های چندسکویی که قبلاً ذکر شد. این محدودیت‌ها توسط سیستم‌عامل‌ها اعمال می‌شوند، بنابراین کار زیادی نمی‌توانیم انجام دهیم، جز اینکه از کاربران بخواهیم درایور یا اجازه‌ها را لغو کنند. اما اگر دستگاه‌های HID یا سریال را منتقل می‌کنید، می‌توانید از مثال libusb پیروی کنید و کتابخانه دیگری را به Fugu API دیگری منتقل کنید. برای مثال، می‌توانید کتابخانه C hidapi را به WebHID منتقل کنید و از این مشکلات که با دسترسی USB سطح پایین مرتبط است به‌طور کامل اجتناب کنید.

نتیجه‌گیری

در این پست نشان داده‌ام که چگونه با کمک Emscripten، Asyncify و Fugu API حتی کتابخانه‌های سطح پایین مانند libusb را می‌توان با چند ترفند ادغام به وب منتقل کرد.

انتقال چنین کتابخانه‌های سطح پایین ضروری و پرکاربردی به‌ویژه پاداش‌بخش است، زیرا به‌نوبه خود، امکان آوردن کتابخانه‌های سطح بالاتر یا حتی کل برنامه‌ها را به وب نیز فراهم می‌کند. این کار تجربه‌هایی را که قبلاً به کاربران یک یا دو پلاتفرم محدود بود، برای انواع دستگاه‌ها و سیستم‌عامل‌ها باز می‌کند و این تجربه‌ها را تنها با یک کلیک روی پیوند دردسترس قرار می‌دهد.

در پست بعدی، مراحل ساخت نسخه نمایشی وب gPhoto2 را توضیح خواهم داد که نه‌تنها اطلاعات دستگاه را بازیابی می‌کند، بلکه به‌طور گسترده‌ای از ویژگی انتقال libusb نیز استفاده می‌کند. در این میان، امیدوارم مثال libusb برایتان الهام‌بخش بوده باشد و نسخه نمایشی را امتحان کنید، با خود کتابخانه بازی کنید، یا شاید حتی پیش بروید و کتابخانه پرکاربرد دیگری را نیز به یکی از میاناهای برنامه‌سازی کاربردی Fugu منتقل کنید.