ניוד אפליקציות USB לאינטרנט. חלק 1: libusb

איך מעבירים לאינטרנט קוד שמקיים אינטראקציה עם מכשירים חיצוניים באמצעות WebAssembly וממשקי API של Fugu.

בפוסט הקודם הראיתי איך להעביר לאינטרנט אפליקציות שמשתמשות בממשקי API של מערכת הקבצים באמצעות File System Access API,‏ WebAssembly ו-Asyncify. עכשיו אני רוצה להמשיך באותו נושא של שילוב ממשקי API של Fugu עם WebAssembly והעברת אפליקציות לאינטרנט בלי לאבד תכונות חשובות.

אראה איך ניתן לנייד אפליקציות שמתקשרים עם התקני USB לאינטרנט על ידי ניוד libusb – ספריית USB פופולרית שכתובה ב-C – ל-WebAssembly (דרך Emscripten), אסינכרוני ו-WebUSB.

קודם כול: הדגמה

הדבר החשוב ביותר שאפשר לעשות כשמעבירים ספרייה הוא לבחור את ההדגמה הנכונה – פעולה שתאפשר לכם להציג את היכולות של הספרייה המדוברת, וכך לבדוק אותה במגוון דרכים, וגם להיות מושך מבחינה חזותית בו-זמנית.

הרעיון שבחרתי היה שלט רחוק ל-DSLR. במיוחד, פרויקט הקוד הפתוח gPhoto2 נמצא בתחום הזה מספיק זמן כדי לבצע הנדסה לאחור ולהטמיע תמיכה במגוון רחב של מצלמות דיגיטליות. הוא תומך בכמה פרוטוקולים, אבל הפרוטוקול שעניין אותי ביותר היה תמיכה ב-USB, שמתבצעת דרך libusb.

אסביר את השלבים ליצירת הדמו הזה בשני חלקים. בפוסט הזה אספר איך העברתי את libusb עצמה, ואילו טריקים עשויים להיות נחוצים כדי להעביר ספריות פופולריות אחרות ל-Fugu APIs. בפוסט השני, ארחיב על ההעברה והשילוב של gPhoto2 עצמו.

בסוף, קיבלתי אפליקציית אינטרנט פעילה שמציגה תצוגה מקדימה של פיד בשידור חי מ-DSLR ושיכולה לשלוט בהגדרות שלה באמצעות USB. לפני שתמשיכו לקרוא את הפרטים הטכניים, מומלץ לצפות בהדגמה בזמן אמת או בהדגמה שהוקלטה מראש:

הדגמה שפועלת במחשב נייד שמחובר למצלמת Sony.

הערה לגבי תכונות ספציפיות למצלמה

יכול להיות ששמתם לב ששינוי ההגדרות בסרטון נמשך זמן מה. בדומה לרוב הבעיות האחרות שעשויות להופיע, הבעיה הזו לא נובעת מהביצועים של WebAssembly או WebUSB, אלא מהאופן שבו gPhoto2 יוצר אינטראקציה עם המצלמה הספציפית שנבחרה לדמו.

Sony a6600 לא חושף API להגדרת ערכים כמו ISO, מהירות צמצם או מהירות תריס באופן ישיר, ובמקום זאת מספק פקודות רק להגדלת או להקטנה של הערכים לפי מספר השלבים שצוין. כדי להקשות על העניין, הפונקציה לא מחזירה גם רשימה של הערכים הנתמכים בפועל – נראה שהרשימה המוחזרת מוטמעת בקובץ ה-code של הרבה דגמי מצלמות של Sony.

כשמגדירים אחד מהערכים האלה, ל-gPhoto2 אין ברירה אלא:

  1. עוברים שלב (או כמה שלבים) לכיוון הערך שנבחר.
  2. ממתינים קצת עד שהמצלמה תעדכן את ההגדרות.
  3. קוראים את הערך שאליו הגיעה המצלמה בפועל.
  4. צריך לבדוק שהשלב האחרון לא קפץ מעל הערך הרצוי ולא מופיע בסוף או בתחילת הרשימה.
  5. ולהתחיל מחדש.

התהליך עשוי להימשך זמן מה, אבל אם המצלמה תומכת בערך הזה, היא תגיע אליו. אם לא, היא תפסיק בערך הקרוב ביותר שנתמך.

סביר להניח שלמצלמות אחרות יהיו קבוצות שונות של הגדרות, ממשקי API בסיסיים ותכונות ייחודיות. חשוב לזכור ש-gPhoto2 הוא פרויקט בקוד פתוח, ופשוט לא ניתן לבצע בדיקה אוטומטית או ידנית של כל דגמי המצלמות הקיימים, לכן תמיד נשמח לקבל דוחות מפורטים על בעיות וביצועים של בקשות תיקון (PRs). עם זאת, חשוב לוודא קודם שאפשר לשחזר את הבעיות באמצעות הלקוח הרשמי של gPhoto2.

הערות חשובות לגבי תאימות בפלטפורמות שונות

לצערנו, ב-Windows כל המכשירים 'המוכרים', כולל מצלמות DSLR, מקבלים מנהל התקן מערכת שלא תואם ל-WebUSB. אם רוצים לנסות את ההדגמה ב-Windows, צריך להשתמש בכלי כמו Zadig כדי לשנות את מנהל ההתקן של ה-DSLR המחובר ל-WinUSB או ל-libusb. הגישה הזו עובדת מצוין אצלי וגם אצל משתמשים רבים אחרים, אבל השימוש בה הוא באחריותך בלבד.

ב-Linux, סביר להניח שתצטרכו להגדיר הרשאות בהתאמה אישית כדי לאפשר גישה למצלמת ה-DSLR דרך WebUSB, אבל זה תלוי בהפצה שלכם.

ב-macOS וב-Android, הדגמה אמורה לפעול 'כמו שהיא'. אם אתם מנסים את האתר בטלפון Android, חשוב לעבור למצב לרוחב כי לא השקעתי הרבה מאמץ כדי להפוך אותו לתגובה (שליחת בקשות תיקון מוזמנת!):

טלפון Android שמחובר למצלמת Canon באמצעות כבל USB-C.
אותו הדגמה שפועלת בטלפון Android. תמונה של Surma.

מדריך מפורט יותר לשימוש ב-WebUSB בפלטפורמות שונות זמין בקטע 'שיקולים ספציפיים לפלטפורמה' במאמר 'פיתוח מכשיר ל-WebUSB'.

הוספת קצה עורפי חדש ל-libusb

עכשיו נעבור לפרטים הטכניים. אפשר לספק ממשק API של shim שדומה ל-libusb (אנשים אחרים עשו זאת בעבר) ולקשר אליו אפליקציות אחרות, אבל הגישה הזו נוטה לשגיאות ומקשה על הרחבה או תחזוקה נוספת. רציתי לעשות את הדברים כמו שצריך, כך שאפשר יהיה לתרום אותם בחזרה למקור ולמזג אותם ל-libusb בעתיד.

למזלנו, בקובץ README של libusb כתוב:

"libusb מופשט באופן פנימי כך שאפשר לנייד אותו למערכות הפעלה אחרות. מידע נוסף זמין בקובץ PORTING.

libusb מובנה כך שממשק ה-API הציבורי נפרד מ'קצוות עורפיים'. הקצוות העורפיים האלה אחראים על הרישום, הפתיחה, הסגירה והתקשורת בפועל עם המכשירים באמצעות ממשקי ה-API ברמה הנמוכה של מערכת ההפעלה. כך המערכת כבר מפשטת את ההבדלים בין 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 שונות ברמה נמוכה בצורת מצביע פונקציות, ולבסוף, גדלים להקצאה לאחסון נתונים פרטיים ברמת המכשיר, ההקשר או ההעברה.

השדות של הנתונים הפרטיים שימושיים לפחות לאחסון כינויים של מערכת ההפעלה לכל הדברים האלה, כי ללא כינויים אנחנו לא יודעים על איזה פריט כל פעולה נתונה חלה. בהטמעה באינטרנט, ה-handles של מערכת ההפעלה יהיו אובייקטי JavaScript של 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))) {}
};

ממשקי API אסינכרוניים לאינטרנט בהקשרים אסינכרוניים של C

עכשיו נדרשה דרך לטפל בממשקי API אסינכררוניים של WebUSB, שבהם libusb מצפה לפעולות סינכרוניות. לשם כך, אפשר להשתמש ב-Asyncify, או באופן ספציפי יותר, בשילוב של Embind דרך val::await().

רציתי גם לטפל בצורה נכונה בשגיאות WebUSB ולהמיר אותן לקודי שגיאה של libusb, אבל כרגע אין ל-Embind דרך לטפל בחריגות של JavaScript או בדחיות של Promise בצד C++. אפשר לעקוף את הבעיה הזו על ידי לכידת דחייה בצד JavaScript והמרת התוצאה באובייקט { error, value } שאפשר עכשיו לנתח בבטחה מצד C++. עשיתי זאת באמצעות שילוב של המאקרו EM_JS וממשקי ה-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()};
  }
};

עכשיו אפשר להשתמש ב-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 צריכה לאחזר רשימה של המכשירים הזמינים. הקצה העורפי חייב להטמיע את הפעולה הזו דרך handler של get_device_list.

הבעיה היא שבניגוד לפלטפורמות אחרות, אין דרך לרשום את כל מכשירי ה-USB שמחוברים לאינטרנט מסיבות אבטחה. במקום זאת, התהליך מחולק לשני חלקים. קודם, אפליקציית האינטרנט מבקשת מכשירים עם מאפיינים ספציפיים באמצעות navigator.usb.requestDevice(), והמשתמש בוחר באופן ידני את המכשיר שאותו הוא רוצה לחשוף או דוחה את בקשת ההרשאה. לאחר מכן, האפליקציה תציג את המכשירים שכבר אושרו וחוברו באמצעות navigator.usb.getDevices().

בהתחלה ניסיתי להשתמש ב-requestDevice() ישירות בהטמעה של ה-handler של get_device_list. עם זאת, הצגת בקשה להרשאה עם רשימה של מכשירים מחוברים נחשבת לפעולה רגישה, והיא חייבת להיות מופעלת על ידי אינטראקציה של המשתמש (למשל, לחיצה על לחצן בדף), אחרת היא תמיד מחזירה התחייבות שנדחתה. לעיתים קרובות, האפליקציות ל-Analytics ירצו להציג רשימה של המכשירים המחוברים בזמן ההפעלה של האפליקציה. לכן, השימוש ב-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 שרציתי להתייחס אליו הוא טיפול באירועים. כפי שמתואר במאמר הקודם, רוב ממשקי ה-API בשפות מערכת כמו C הם סינכרוניים, וטיפול באירועים הוא לא יוצא מן הכלל. בדרך כלל היא מוטמעת באמצעות לולאה אינסופית ש"סקרים" (מנסה לקרוא נתונים או חוסמת הפעלה עד שחלק מהנתונים זמינים) מקבוצה של מקורות קלט/פלט חיצוניים, וכשלפחות אחד מהם מגיב, היא מעבירה אותה כאירוע ל-handler המתאים. בסיום הטיפול, הבקרה חוזרת לולאה ומתבצעת השהיה לבדיקה נוספת.

יש כמה בעיות בגישה הזו באינטרנט.

ראשית, WebUSB לא חושף ולא יכול לחשוף כינויים גולמיים של המכשירים הבסיסיים, ולכן אי אפשר לבצע סקרים ישירות עליהם. שנית, libusb משתמשת בממשקי ה-API eventfd ו-pipe לאירועים אחרים, וגם לטיפול בהעברות במערכות הפעלה ללא סמלי מכשיר גולמיים. עם זאת, eventfd לא נתמך כרגע ב-Emscripten, וגם pipe נתמך, אבל כרגע הוא לא עומד במפרט ולא ניתן להמתין לאירועים.

לבסוף, הבעיה הגדולה ביותר היא שלאינטרנט יש לולאת אירועים משלו. לולאת האירועים הגלובלית הזו משמשת לכל פעולות הקלט/פלט החיצוניות (כולל fetch(), שעונים או, במקרה הזה, WebUSB), והיא מפעילה אירועים או מנהלי Promise בכל פעם שהפעולות המתאימות מסתיימות. הפעלה של לולאת אירועים אינסופית אחרת בתצוגת עץ תמנע מלולאת האירועים של הדפדפן להתקדם, כלומר לא רק שממשק המשתמש לא יגיב, אלא גם שהקוד לא יקבל אף פעם התראות לגבי אותם אירועי קלט/פלט שהוא מחכה להם. בדרך כלל זה מוביל לנעילה מרובת משתמשים (deadlock), וזה מה שקרה גם כשניסיתי להשתמש ב-libusb בהדגמה. הדף קפא.

כמו בפעולות קלט/פלט חסומות אחרות, כדי להעביר ל-Web לולאות אירועים כאלה, המפתחים צריכים למצוא דרך להריץ את הלולאות האלה בלי לחסום את ה-thread הראשי. אחת מהדרכים היא לארגן מחדש את האפליקציה כדי לטפל באירועי קלט/פלט ב-thread נפרד ולהעביר את התוצאות בחזרה ל-thread הראשי. האפשרות השנייה היא להשתמש ב-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() כדי לבדוק אם כבר דווחו אירועים מהקצה העורפי. אם יש כאלה, הלולאה תפסיק. אחרת, ההטמעה של poll() ב-Emscripten תחזיר מיד את הערך 0.
  2. שיחות emscripten_sleep(0). הפונקציה הזו משתמשת ב-Asyncify וב-setTimeout() מתחת לפני השטח, והיא משמשת כאן כדי להחזיר את השליטה ל-loop האירועים הראשי של הדפדפן. כך הדפדפן יכול לטפל בכל האינטראקציות של המשתמשים ואירועי קלט/פלט (I/O), כולל WebUSB.
  3. בודקים אם פג התוקף של הזמן הקצוב לתפוגה, ואם לא, ממשיכים את הלולאה.

כפי שצוין בתגובה, הגישה הזו לא הייתה אופטימלית כי היא שמרה ושיחזרה את כל סטאק הקריאות באמצעות Asyncify גם כשעדיין לא היו אירועי USB לטיפול (ברוב הזמן), וגם כי ל-setTimeout() עצמו יש משך זמן מינימלי של 4ms בדפדפנים מודרניים. עם זאת, המערכת עבדה מספיק טוב כדי ליצור שידור חי של 13-14FPS מ-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() משמש כדי 'להתעורר' ממצב שינה אסינכרוני כשאירוע 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 מ-13-14 FPS ל-30 FPS ויותר באופן עקבי, מספיק כדי להציג פיד בשידור חי בצורה חלקה.

בניית מערכת והבדיקה הראשונה

אחרי שסיימתי את הקצה העורפי, נאלצתי להוסיף אותו ל-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, שמטפלת בחיוב וביצירת מופע של JavaScript ו-WebAssembly.

שנית, מכיוון שאני משתמש ב-Embind וב-Asyncify, עליי להפעיל את התכונות האלה (--bind -s ASYNCIFY) וגם לאפשר התרחבות דינמית של הזיכרון (-s ALLOW_MEMORY_GROWTH) באמצעות פרמטרים של קישור. לצערנו, אין לספרייה דרך לדווח על הדגלים האלה למקשר, לכן כל אפליקציה שמשתמשת ביציאת הליבוס הזו תצטרך להוסיף את אותם דגלי קישור גם לתצורת ה-build שלה.

לסיום, כפי שצוין קודם, ב-WebUSB נדרש ספירת מכשירים באמצעות תנועת משתמש. הדוגמאות והבדיקות של libusb מניחות שהן יכולות לספור מכשירים בזמן ההפעלה, והן נכשלות עם שגיאה ללא שינויים. במקום זאת, נאלצתי להשבית את הביצוע האוטומטי (-s INVOKE_RUN=0) ולהציג את השיטה הידנית callMain() (-s EXPORTED_RUNTIME_METHODS=...).

אחרי שסיימתי את התהליך, יכולתי להציג את הקבצים שנוצרו באמצעות שרת אינטרנט סטטי, לאתחל את WebUSB ולהריץ את קובצי ההפעלה של ה-HTML באופן ידני בעזרת כלי הפיתוח.

צילום מסך שבו מוצג חלון של Chrome עם כלי הפיתוח פתוח בדף &#39;testlibusb&#39; שמוצג באופן מקומי. מסוף DevTools מבצע הערכה של navigator.usb.requestDevice({ filters: [] })‎, שהפעילה הודעה לבקשת הרשאה, והוא מבקש כרגע מהמשתמש לבחור מכשיר USB שצריך לשתף עם הדף. ILCE-6600 (מצלמה של Sony) נבחרה כרגע.

צילום מסך של השלב הבא, כאשר כלי הפיתוח עדיין פתוח. אחרי שבוחרים את המכשיר, מסוף ה-CLI מבצע הערכה של ביטוי חדש, `Module.callMain([&#39;-v&#39;])`, שמפעיל את האפליקציה testlibusb במצב מפורט. בפלט מוצג מידע מפורט מגוון על מצלמת ה-USB שחוברה בעבר: יצרן Sony, מוצר ILCE-6600, מספר סידורי, תצורה וכו&#39;.

זה לא נראה הרבה, אבל כשמעבירים ספריות לפלטפורמה חדשה, מגיעים לשלב שבו הפלט תקין בפעם הראשונה, וזה מרגש מאוד!

שימוש ביציאה

כפי שצוין למעלה, השקע תלוי בכמה תכונות של Emscripten שצריך להפעיל כרגע בשלב הקישור של האפליקציה. אם אתם רוצים להשתמש ביציאת libusb הזו באפליקציה שלכם, עליכם לבצע את הפעולות הבאות:

  1. מורידים את libusb העדכני ביותר כקובץ ארכיון כחלק מה-build או מוסיפים אותו כ-submodule של git בפרויקט.
  2. מריצים את autoreconf -fiv בתיקייה libusb.
  3. מריצים את emconfigure ./configure –host=wasm32 –prefix=/some/installation/path כדי לאתחל את הפרויקט לצורך הידור חוצה-פלטפורמות ולהגדיר את הנתיב שבו רוצים להציב את הארטיפקטים שנוצרו.
  4. מריצים את emmake make install.
  5. מכוונים את האפליקציה או את הספרייה ברמה גבוהה יותר כדי לחפש את הליבוס מתחת לנתיב שבחרתם קודם.
  6. מוסיפים את הדגלים הבאים לארגומנטים של הקישורים באפליקציה: --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH.

על הספרייה יש כרגע כמה מגבלות:

  • אין תמיכה בביטול העברה. זו מגבלה של WebUSB, שנובעת מהיעדר ביטול העברה מפלטפורמות שונות ב-libusb עצמו.
  • אין תמיכה בהעברה איזוקרונית. לא אמור להיות קשה להוסיף אותו באמצעות ביצוע של מצבי ההעברה הקיימים כדוגמאות, אבל זה גם מצב די נדיר ולא היו לי מכשירים שבהם אפשר לבדוק אותו, אז בינתיים השארתי אותו במצב לא נתמך. אם יש לכם מכשירים כאלה ואתם רוצים לתרום לספרייה, נשמח לקבל בקשות לשינוי קוד (PRs).
  • המגבלות בפלטפורמות השונות שצוינו קודם. מערכות ההפעלה אוכפות את המגבלות האלה, כך שאין לנו אפשרות לעשות כאן הרבה, מלבד לבקש מהמשתמשים לעקוף את מנהל ההתקן או את ההרשאות. עם זאת, אם אתם מעבירים מכשירים מסוג HID או מכשירים עם יציאה טורית, תוכלו לפעול לפי הדוגמה של libusb ולהעביר ספרייה אחרת ל-Fugu API אחר. לדוגמה, אפשר להעביר ספריית C‏ hidapi ל-WebHID ולהימנע לגמרי מהבעיות האלה שקשורות לגישה ברמה נמוכה ל-USB.

סיכום

בפוסט הזה הראיתי איך, בעזרת Emscripten,‏ Asyncify ו-Fugu APIs, אפשר להעביר לאינטרנט גם ספריות ברמה נמוכה כמו libusb, באמצעות כמה טריקים לשילוב.

העברת ספריות חיוניות ונפוצות כאלה שנמצאות בשימוש נרחב היא מתגמלת במיוחד, מפני שבתורו ניתן להביא לאינטרנט גם ספריות ברמה גבוהה יותר או אפילו אפליקציות שלמות. כך תיפתח האפשרות שחוויות היו מוגבלות בעבר למשתמשים בפלטפורמה אחת או שתיים, לכל סוגי המכשירים ומערכות ההפעלה, והחוויות האלה יהיו זמינות בלחיצה אחת על הקישור.

בפוסט הבא אציג את השלבים ליצירת הדמו של gPhoto2 לאינטרנט, שמאחורי הקלעים משתמש גם בתכונת ההעברה של libusb, ולא רק מאחזר את פרטי המכשיר. בינתיים, אני מקווה שהדוגמה ל-libusb נתנה לכם השראה ותנסו את הדמו, תשחקו עם הספרייה עצמה או אולי אפילו תעבירו ספרייה אחרת בשימוש נרחב לאחד מממשקי ה-API של Fugu.