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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

כמו במקרה של פעולות קלט/פלט חוסמות אחרות, כדי להעביר לולאות אירועים כאלה לאינטרנט, המפתחים צריכים למצוא דרך להריץ את הלולאות האלה בלי לחסום את ה-thread הראשי. דרך אחת היא לארגן מחדש את הקוד (Refactoring) של האפליקציה כדי לטפל באירועי קלט/פלט ב-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() מאחורי הקלעים, ומשמשת כאן כדי להחזיר את השליטה ללולאת האירוע הראשית של הדפדפן. כך הדפדפן יכול לטפל בכל האינטראקציות של המשתמשים ובאירועי הקלט/פלט, כולל WebUSB.
  3. בודקים אם הזמן הקצוב שצוין כבר חלף, ואם לא, ממשיכים את הלולאה.

כמו שכתוב בהערה, הגישה הזו לא הייתה אופטימלית, כי היא המשיכה לשמור ולשחזר את כל סטאק הביצוע באמצעות Asyncify גם כשלא היו עדיין אירועי USB לטיפול (וזה מה שקורה רוב הזמן), וגם כי ל-setTimeout() עצמו יש משך מינימלי של 4 אלפיות השנייה בדפדפנים מודרניים. עם זאת, היא פעלה מספיק טוב כדי להפיק שידור חי של 13-14 FPS ממצלמת DSLR בהוכחת היתכנות (PoC).

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

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

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

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

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

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

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

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

שימוש ביציאה

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

  1. מורידים את הגרסה האחרונה של libusb כקובץ ארכיון כחלק מה-build, או מוסיפים אותה כמודול משנה של 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 עצמו.
  • אין תמיכה בהעברה איזוכרונית. לא אמור להיות קשה להוסיף אותו, אפשר להשתמש בהטמעה של מצבי העברה קיימים כדוגמאות, אבל זה גם מצב די נדיר ולא היו לי מכשירים לבדוק אותו, אז בינתיים השארתי אותו כלא נתמך. אם יש לכם מכשירים כאלה ואתם רוצים לתרום לספרייה, נשמח לקבל בקשות משיכה (PR).
  • המגבלות שצוינו קודם לגבי פלטפורמות שונות. המגבלות האלה מוטלות על ידי מערכות ההפעלה, כך שאין הרבה מה לעשות, חוץ מלבקש מהמשתמשים לשנות את מנהל ההתקן או את ההרשאות. עם זאת, אם אתם מעבירים מכשירים עם יציאה טורית או מכשירי ממשק אנושי, אתם יכולים לפעול לפי הדוגמה של libusb ולהעביר ספרייה אחרת ל-Fugu API אחר. לדוגמה, אתם יכולים להעביר ספריית C בשם hidapi אל WebHID ולעקוף לגמרי את הבעיות האלה, שקשורות לגישה ל-USB ברמה נמוכה.

סיכום

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

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

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