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

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

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

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

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

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

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

למרבה המזל, בlibusb README כתוב:

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

מבדיקת המאפיינים עולה שה-struct כולל את שם הקצה העורפי, קבוצת היכולות שלו, רכיבי handler לפעולות USB שונות ברמה נמוכה בצורת מצביעי פונקציות, ולבסוף, גדלים שצריך להקצות לאחסון נתונים פרטיים ברמת המכשיר/ההקשר/ההעברה.

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

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.

ניוד לולאות של אירועים לאינטרנט

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

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

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

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

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

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

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

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

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

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

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

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

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

שימוש ביציאה

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

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

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

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

סיכום

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

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

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