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

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

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

אראה איך ניתן לנייד אפליקציות שמתקשרים עם התקני USB לאינטרנט על ידי ניוד libusb – ספריית USB פופולרית שכתובה ב-C – ל-WebAssembly (דרך Emscripten), אסינכרוני ו-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, הקפידו לעבור לפריסה לרוחב כי לא השקעתי הרבה מאמץ כדי להפוך אותו לרספונסיבי (PR יתקבלו בברכה!):

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

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

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

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

למזלך, libusb README אומר:

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

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

לאחר בדיקה של המאפיינים, אפשר לראות שהמבנה כולל את השם של הקצה העורפי, קבוצת היכולות שלו, handlers של פעולות 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

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

ניוד אירועים בלופ לאינטרנט

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

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

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

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

כמו בקלט/פלט (I/O) אחרים שחוסמים, כדי להעביר לולאות אירועים כאלה לאינטרנט, מפתחים צריכים למצוא דרך להפעיל את הלולאות האלה מבלי לחסום את ה-thread הראשי. אחת הדרכים היא לשנות את סדר הרכיבים של האפליקציה כדי לטפל באירועי קלט/פלט (I/O) בשרשור נפרד, ולהעביר את התוצאות לרשת הראשית. הדרך השנייה היא להשתמש באסינכרוני כדי להשהות את הלולאה ולהמתין לאירועים באופן שאינו חוסם.

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

כפי שצוין בתגובה, הגישה הזו לא הייתה אופטימלית כי היא ממשיכה לשמור ולשחזר את כל מקבץ השיחות באמצעות Asyncify גם כשעוד לא היו אירועי USB שצריך לטפל בהם (ברוב המקרים), ובגלל שמשך הזמן המינימלי של setTimeout() בדפדפנים מודרניים הוא 4 אלפיות השנייה. ובכל זאת, הוא עבד טוב מספיק כדי להפיק סטרימינג בשידור חי של 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; שנמצא בשירות מקומי. מסוף כלי הפיתוח מבצע הערכה של &#39;navigator.usb.requestDevice({ filters: [] })&#39;, שבעקבותיו התקבלה בקשה להרשאה, וכרגע המשתמש צריך לבחור התקן USB שצריך לשתף עם הדף. ILCE-6600 (מצלמה של Sony) נבחר כרגע.

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

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

שימוש ביציאה

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

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

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

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

סיכום

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

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

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