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

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

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

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

الأهم فالمهم: عرض توضيحي

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

الفكرة التي اخترتها كانت جهاز التحكم عن بعد DSLR. وعلى وجه التحديد، تم إنشاء مشروع مفتوح المصدر 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

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

لحسن الحظ، تقول libusb README:

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

نقل حلقات الأحداث إلى الويب

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

هناك مشكلتان في هذا الأسلوب على الويب.

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

أخيرًا، المشكلة الأكبر هي أنّ شبكة الإنترنت لديها حلقة أحداث خاصة بها. ويتم استخدام حلقة الأحداث العامة هذه لأي عمليات إدخال/إخراج خارجية (بما في ذلك fetch() أو الموقتات أو في هذه الحالة، WebUSB)، ويستدعي الحدث أو معالِجات Promise عند انتهاء العمليات المقابلة. سيؤدي تنفيذ حلقة أحداث لانهائية أخرى ومدمجة إلى حظر تقدّم حلقة أحداث المتصفّح على الإطلاق، ما يعني أنّ واجهة المستخدم لن تستجيب فحسب، وأنّ الرمز لن يتلقّى مطلقًا إشعارات لأحداث I/O نفسها التي تنتظرها. يؤدي هذا عادةً إلى توقف تام، وهذا ما حدث عندما حاولت استخدام 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; مفتوحة على صفحة &quot;testlibusb&quot; معروضة محليًا. تعمل وحدة التحكّم في أدوات مطوّري البرامج على تقييم &quot;navigator.usb.requestDevice({Filters: [] })&quot;، ما أدّى إلى ظهور طلب للحصول على إذن، كما أنّها تطلب حاليًا من المستخدم اختيار جهاز USB يجب مشاركته مع الصفحة. تم حاليًا اختيار ILCE-6600 (كاميرا Sony).

لقطة شاشة للخطوة التالية، بينما لا تزال أدوات مطوّري البرامج مفتوحة بعد اختيار الجهاز، قيّمت وحدة التحكّم تعبيرًا جديدًا &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 APIs.

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

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