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

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

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

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

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

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

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

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