جارٍ نقل تطبيقات 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 نفسه.

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

أخيرًا، تتمثل المشكلة الأكبر في أنّ الويب لديه حلقة أحداث خاصة به. تُستخدَم حلقة الأحداث الشاملة هذه لأي عمليات إدخال/إخراج خارجية (بما في ذلك 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 لقطة في الثانية من كاميرا 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 لقطة في الثانية إلى أكثر من 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; بعد اختيار الجهاز، قيّمت وحدة التحكّم تعبيرًا جديدًا &quot;Module.callMain([&#39;-v&#39;])&quot;، والذي نفّذ تطبيق &quot;testlibusb&quot; في وضع التشغيل المطوَّل. تعرِض النتيجة معلومات مفصّلة مختلفة عن كاميرا 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 أيضًا.