جارٍ نقل تطبيقات USB إلى الويب. الجزء 1: libusb

تعرَّف على كيفية نقل الرموز البرمجية التي تتفاعل مع الأجهزة الخارجية إلى الويب باستخدام WebAssembly وواجهة برمجة التطبيقات Fugu.

في مقالة سابقة، شرحت كيفية نقل التطبيقات التي تستخدم واجهات برمجة تطبيقات نظام الملفات إلى الويب باستخدام File System Access API وWebAssembly وAsyncify. نريد الآن مواصلة الموضوع نفسه المتعلق بدمج واجهات برمجة تطبيقات Fugu مع WebAssembly ونقل التطبيقات إلى الويب بدون فقدان الميزات المهمة.

سأوضّح كيفية نقل التطبيقات التي تتواصل مع أجهزة USB إلى الويب من خلال نقل libusb، وهي مكتبة USB رائجة مكتوبة بلغة C، إلى WebAssembly (من خلال Emscripten) وAsyncify وWebUSB.

أولاً، عرض توضيحي

إنّ أهم إجراء يجب اتّخاذه عند نقل مكتبة هو اختيار العرض الترويجي المناسب، أي عرض يعرض إمكانات المكتبة المنقولة، ويسمح لك باختبارها بطرق متنوعة، ويجذب الانتباه من الناحية المرئية في الوقت نفسه.

الفكرة التي اخترتها هي التحكّم عن بُعد في كاميرا رقمية أحادية العدسة. على وجه الخصوص، تم استخدام مشروع مفتوح المصدر gPhoto2 في هذا المجال لفترة طويلة بما يكفي لعكس الهندسة وتنفيذ التوافق مع مجموعة كبيرة من الكاميرات الرقمية. وهو متوافق مع العديد من البروتوكولات، ولكن البروتوكول الذي يهمّني هو بروتوكول USB الذي يتم تنفيذه من خلال libusb.

سأصف خطوات إنشاء هذا العرض الترويجي في جزأين. في هذه المقالة، سأوضّح كيفية نقل libusb نفسها، والحيل التي قد تكون ضرورية لنقل المكتبات الشائعة الأخرى إلى واجهات برمجة تطبيقات Fugu. في المشاركة الثانية، سأوضّح تفاصيل حول نقل gPhoto2 ودمجه.

في النهاية، حصلت على تطبيق ويب يعمل بشكل جيد يعرض معاينة للخلاصة المباشرة من كاميرا رقمية أحادية العدسة ويمكنه التحكّم في إعداداتها عبر USB. يمكنك الاطّلاع على العرض المباشر أو العرض التوضيحي المسجّل مسبقًا قبل الاطّلاع على التفاصيل الفنية:

العرض التجريبي الذي يتم تشغيله على كمبيوتر محمول متصل بكاميرا Sony

ملاحظة حول المشاكل المتعلقة بالكاميرا

ربما لاحظت أنّ تغيير الإعدادات يستغرق بعض الوقت في الفيديو. مثل معظم المشاكل الأخرى التي قد تواجهها، لا يرجع سبب حدوث ذلك إلى أداء WebAssembly أو WebUSB، بل إلى كيفية تفاعل gPhoto2 مع الكاميرا المحدّدة التي تم اختيارها للعرض الترويجي.

لا توفّر كاميرا Sony a6600 واجهة برمجة تطبيقات لضبط قيم مثل ISO أو فتحة العدسة أو سرعة الغالق مباشرةً، بل تقدّم بدلاً من ذلك أوامر لزيادتها أو خفضها بعدد الخطوات المحدّد. ولتزيد الأمور تعقيدًا، لا تعرض القائمة أيضًا القيم المسموح بها فعليًا، إذ يبدو أنّ القائمة المعروضة مبرمَجة بشكل ثابت في العديد من طُرز كاميرات Sony.

عند ضبط إحدى هذه القيم، ليس لدى gPhoto2 خيار آخر سوى:

  1. اتّخِذ خطوة (أو بضع خطوات) في اتجاه القيمة التي اخترتها.
  2. انتظِر قليلاً حتى تعدِّل الكاميرا الإعدادات.
  3. يُرجى قراءة القيمة التي تم ضبط الكاميرا عليها فعليًا.
  4. تأكَّد من أنّ الخطوة الأخيرة لم تتخطّ القيمة المطلوبة ولم تنتقل إلى نهاية القائمة أو بدايتها.
  5. والتكرار.

قد يستغرق ذلك بعض الوقت، ولكن إذا كانت القيمة متوافقة مع الكاميرا، ستصل إليها، وإذا لم تكن كذلك، ستتوقف عند أقرب قيمة متوافقة.

من المحتمل أن تتضمّن الكاميرات الأخرى مجموعات مختلفة من الإعدادات وواجهات برمجة التطبيقات الأساسية والمشاكل. يُرجى العِلم أنّ 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

لننتقل الآن إلى التفاصيل الفنية. على الرغم من أنّه من الممكن توفير واجهة برمجة تطبيقات احتياطية مشابهة لواجهة libusb (سبق أن نفّذ أشخاص آخرون ذلك) وربط تطبيقات أخرى بها، إلا أنّ هذا النهج معرّض للخطأ ويصعّب إجراء أي توسيع أو صيانة إضافية. أردت إجراء الأمور بشكل صحيح، بطريقة يمكن من خلالها المساهمة في الإصدار العلني للدمج في libusb في المستقبل.

لحسن الحظ، يشير ملف README الخاص بـ libusb إلى ما يلي:

"يتمّ تجريد libusb داخليًا بطريقة يُتوقّع أن يتمّ نقلها إلى أنظمة التشغيل الأخرى. يُرجى الاطّلاع على ملف PORTING للحصول على مزيد من المعلومات.

تم تنظيم libusb بطريقة تجعل واجهة برمجة التطبيقات العامة منفصلة عن "البرامج الخلفية". وتتحمّل هذه الخلفيات مسؤولية إدراج الأجهزة وفتحها وإغلاقها والتواصل معها من خلال واجهات برمجة التطبيقات ذات المستوى المنخفض لنظام التشغيل. وهذا هو السبب في أنّ 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 منخفضة المستوى مختلفة في شكل مؤشرات دوال، وأخيراً، الأحجام المخصّصة لتخزين البيانات الخاصة على مستوى الجهاز أو السياق أو النقل.

تكون حقول البيانات الخاصة مفيدة على الأقل لتخزين أسماء معرِّفات نظام التشغيل لكل هذه العناصر، لأنّه بدون أسماء المعرِّفات، لا نعرف العنصر الذي تنطبق عليه أي عملية معيّنة. في عملية التنفيذ على الويب، ستكون عناصر نظام التشغيل هي عناصر 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))) {}
};

واجهات برمجة التطبيقات غير المتزامنة للويب في سياقات C المتزامنة

نحتاج الآن إلى طريقة لمعالجة واجهات برمجة تطبيقات WebUSB غير المتزامنة حيث تتوقّع libusb عمليات متزامنة. لهذا الغرض، يمكنني استخدام Asyncify، أو بشكل أكثر تحديدًا، دمج Embind من خلال val::await().

أردت أيضًا معالجة أخطاء WebUSB بشكل صحيح وتحويلها إلى رموز أخطاء libusb، ولكن لا تتوفّر حاليًا في Embind أي طريقة لمعالجة استثناءات JavaScript أو رفض Promise من جانب C++. يمكن حلّ هذه المشكلة من خلال رصد رفض من جانب JavaScript وتحويل النتيجة إلى عنصر { error, value } يمكن الآن تحليله بأمان من جانب C++. لقد أجريتُ ذلك باستخدام واجهتَي برمجة التطبيقات EM_JS و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 قائمة بالأجهزة المتاحة. يجب أن تنفّذ الخلفية هذه العملية من خلال معالِج 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 أريد مناقشته وهو معالجة الأحداث. كما هو موضّح في المقالة السابقة، تكون معظم واجهات برمجة التطبيقات في لغات النظام مثل C متزامنة، ولا يُستثنى من ذلك معالجة الأحداث. ويتم تنفيذه عادةً من خلال حلقة لا نهائية "تستقصي" (تحاول قراءة البيانات أو حظر التنفيذ إلى أن تصبح بعض البيانات متاحة) من مجموعة من مصادر الإدخال/الإخراج الخارجية، وعندما يستجيب مصدر واحد على الأقل، يتم تمريره كحدث إلى معالِج المهام المقابل. بعد انتهاء معالِج الأحداث، يعود العنصر إلى الحلقة ويتوقّف مؤقتًا لإجراء عملية فحص أخرى.

هناك بعض المشاكل في هذا النهج على الويب.

أولاً، لا يعرِض WebUSB معالِم الأجهزة الأساسية الأولية ولا يمكنه ذلك، لذا لا يمكن الاستعلام عنها مباشرةً. ثانيًا، يستخدم libusb واجهات برمجة التطبيقات eventfd وpipe للأحداث الأخرى بالإضافة إلى معالجة عمليات النقل على أنظمة التشغيل التي لا تتضمّن عناصر تحكّم في الأجهزة الأوّلية، ولكن eventfd غير متوافقة حاليًا مع Emscripten، وpipe، على الرغم من توافقها، لا تتوافق حاليًا مع المواصفات ولا يمكنها الانتظار إلى أن تحدث الأحداث.

أخيرًا، تتمثل المشكلة الأكبر في أنّ الويب لديه حلقة أحداث خاصة به. تُستخدَم حلقة الأحداث الشاملة هذه لأي عمليات I/O خارجية (بما في ذلك fetch() أو الموقّتات أو WebUSB في هذه الحالة)، وهي تستدعي معالجات الأحداث أو Promise عند انتهاء العمليات المقابلة. سيؤدي تنفيذ حلقة أحداث أخرى متداخلة وغير محدودة إلى منع حلقة أحداث المتصفّح من التقدّم، ما يعني أنّ واجهة المستخدم لن تتوقف عن الاستجابة فحسب، بل لن تتلقّى الرموز البرمجية أبدًا إشعارات بأحداث الإدخال/الإخراج نفسها التي تنتظرها. يؤدي ذلك عادةً إلى حدوث مشكلة في الربط، وهذا ما حدث عندما حاولت استخدام libusb في عرض توضيحي أيضًا. توقّفت الصفحة عن العمل.

كما هو الحال مع عمليات الإدخال والإخراج الأخرى التي تعمل على حظر المعالجة، لنقل حلقات الأحداث هذه إلى الويب، على المطوّرين العثور على طريقة لتشغيل هذه الحلقات بدون حظر سلسلة التعليمات الرئيسية. ومن الطرق التي يمكنك اتّباعها إعادة صياغة التطبيق للتعامل مع أحداث الإدخال والإخراج في سلسلة مهام منفصلة وإعادة النتائج إلى السلسلة الرئيسية. أما الطريقة الأخرى، فهي استخدام 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() للتحقّق مما إذا تم الإبلاغ عن أيّ أحداث من خلال الخلفية حتى الآن. إذا كانت هناك بعض الأخطاء، تتوقف حلقة الإعادة. بخلاف ذلك، سيعرض تنفيذ Emscripten لـ poll() على الفور 0.
  2. المكالمات emscripten_sleep(0). تستخدِم هذه الدالة Asyncify وsetTimeout() في الخلفية، ويتم استخدامها هنا لإعادة التحكّم إلى حلقة أحداث المتصفّح الرئيسية. ويسمح ذلك للمتصفح بمعالجة أي تفاعلات للمستخدِم وأحداث الإدخال/الإخراج، بما في ذلك WebUSB.
  3. تحقَّق ممّا إذا كان وقت الانتظار المحدّد قد انتهى أم لا، وإذا لم يكن كذلك، واصِل حلقة التكرار.

كما يشير التعليق، لم يكن هذا النهج مثاليًا، لأنّه كان يستمر في حفظ واستعادة حزمة الاستدعاء بالكامل باستخدام Asyncify حتى في حال عدم توفّر أحداث USB للتعامل معها بعد (وهو ما يحدث معظم الوقت)، ولأنّ setTimeout() نفسها لها مدة دنيا تبلغ 4 مللي ثانية في المتصفحات الحديثة. ومع ذلك، كان الأداء جيدًا بما يكفي لإنشاء بث مباشر بمعدّل من 13 إلى 14 لقطة في الثانية من كاميرا رقمية أحادية العدسة في مرحلة إثبات المفهوم.

وفي وقت لاحق، قرّرت تحسينه من خلال الاستفادة من نظام أحداث المتصفّح. هناك عدة طرق يمكن من خلالها تحسين هذا التنفيذ بشكل أكبر، ولكن في الوقت الحالي، اخترتُ بث أحداث مخصّصة مباشرةً على العنصر الشامل، بدون ربطها ببنية بيانات 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 لقطة في الثانية إلى أكثر من 30 لقطة في الثانية بشكلٍ ثابت، وهو ما يكفي لعرض بث مباشر سلس.

إنشاء النظام والاختبار الأول

بعد الانتهاء من تطوير الخلفية، كان عليّ إضافتها إلى 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 هذا إضافة علامات المُجمِّع نفسها إلى إعدادات الإنشاء أيضًا.

أخيرًا، كما ذكرنا سابقًا، تتطلّب WebUSB إجراء عملية إحصاء للأجهزة من خلال إيماءة المستخدم. تفترض أمثلة واختبارات libusb أنّه يمكنها إحصاء الأجهزة عند بدء التشغيل، وتؤدي إلى حدوث خطأ بدون إجراء تغييرات. بدلاً من ذلك، اضطررت إلى إيقاف التنفيذ التلقائي (-s INVOKE_RUN=0) وعرض طريقة callMain() اليدوية (-s EXPORTED_RUNTIME_METHODS=...).

بعد الانتهاء من كل هذه الإجراءات، تمكّنت من عرض الملفات التي تم إنشاؤها باستخدام خادم ويب ثابت، وإعداد WebUSB، وتشغيل ملفات HTML القابلة للتنفيذ يدويًا باستخدام أدوات المطوّرين.

لقطة شاشة تعرض نافذة Chrome مع فتح &quot;أدوات مطوّري البرامج&quot; على صفحة testlibusb التي يتم عرضها محليًا تقيِّم وحدة تحكّم DevTools دالة navigator.usb.requestDevice({ filters: [] })‎، ما أدّى إلى ظهور طلب إذن، وهي تطلب حاليًا من المستخدم اختيار جهاز USB الذي يجب مشاركته مع الصفحة. تم اختيار ILCE-6600 (كاميرا Sony) حاليًا.

لقطة شاشة للخطوة التالية، مع استمرار فتح &quot;أدوات مطوّري البرامج&quot; بعد اختيار الجهاز، قيّمت Console تعبيرًا جديدًا هو Module.callMain([&#39;-v&#39;])، ما أدّى إلى تنفيذ تطبيق testlibusb في الوضع التفصيلي. تعرِض النتيجة معلومات مفصّلة مختلفة عن كاميرا USB التي تم ربطها سابقًا: الشركة المصنّعة Sony، والمنتج ILCE-6600، والرقم التسلسلي، والإعدادات، وما إلى ذلك.

قد لا يبدو هذا الأمر مهمًا، ولكن عند نقل المكتبات إلى منصة جديدة، من المثير جدًا الوصول إلى المرحلة التي تُنتج فيها مخرجات صالحة للمرة الأولى.

استخدام المنفذ

كما ذكرنا أعلاه، يعتمد هذا الإصدار على بعض ميزات Emscripten التي يجب تفعيلها حاليًا في مرحلة ربط التطبيق. إذا كنت تريد استخدام منفذ libusb هذا في تطبيقك، إليك الإجراءات التي يجب اتّباعها:

  1. نزِّل أحدث إصدار من libusb إما كأرشيف كجزء من عملية الإنشاء أو أضِفه كوحدة فرعية في 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 نفسها.
  • لا تتوفّر إمكانية النقل المتزامن. من المفترض ألا يكون من الصعب إضافته من خلال اتّباع طريقة تنفيذ أوضاع النقل الحالية كأمثلة، ولكنّه أيضًا وضع نادر إلى حدٍ ما ولم يكن لديّ أي أجهزة لاختباره عليها، لذلك تركته حاليًا على أنّه غير متوافق. إذا كانت لديك هذه الأجهزة وأردت المساهمة في المكتبة، يُرجى إرسال الطلبات المقدَّمة من المطوّرين.
  • القيود المذكورة سابقًا على جميع المنصات تفرض أنظمة التشغيل هذه القيود، لذا لا يمكننا فعل الكثير هنا، باستثناء مطالبة المستخدمين بإلغاء برنامج التشغيل أو الأذونات. ومع ذلك، إذا كنت بصدد نقل أجهزة HID أو الأجهزة التسلسلية، يمكنك اتّباع مثال libusb ونقل بعض المكتبات الأخرى إلى واجهة برمجة تطبيقات Fugu API أخرى. على سبيل المثال، يمكنك نقل مكتبة C hidapi إلى WebHID وتجنُّب هذه المشاكل المرتبطة بالوصول إلى USB على مستوى منخفض تمامًا.

الخاتمة

في هذا المنشور، أوضحتُ كيفية نقل حتى المكتبات ذات المستوى المنخفض، مثل libusb، إلى الويب باستخدام واجهات برمجة التطبيقات Emscripten وAsyncify وFugu، وذلك من خلال بعض حيل الدمج.

إنّ نقل هذه المكتبات الأساسية والمستخدَمة على نطاق واسع والتي تقع في المستوى الأدنى من البرمجة يُعدّ أمرًا مفيدًا بشكل خاص، لأنّه يتيح بدوره نقل مكتبات ذات مستوى أعلى أو حتى تطبيقات كاملة إلى الويب أيضًا. ويؤدي ذلك إلى إتاحة تجارب كانت في السابق متاحة لمستخدمي منصة واحدة أو منصتين فقط على جميع أنواع الأجهزة وأنظمة التشغيل، ما يجعل هذه التجارب متاحة بنقرة واحدة على الرابط.

في المشاركة التالية، سأشرح الخطوات المُتعلّقة بإنشاء الإصدار التجريبي من gPhoto2 على الويب الذي لا يسترجع معلومات الجهاز فحسب، بل يستخدم أيضًا ميزة النقل في libusb على نطاق واسع. في هذه الأثناء، آمل أن يكون مثال libusb مصدر إلهام لك وأن تجرب العرض الترويجي أو تلعب بالمكتبة نفسها أو ربما تنقل مكتبة أخرى مستخدمة على نطاق واسع إلى إحدى واجهات برمجة تطبيقات Fugu أيضًا.