تعرَّف على كيفية نقل الرموز البرمجية التي تتفاعل مع الأجهزة الخارجية إلى الويب باستخدام 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. يمكنك الاطّلاع على العرض التوضيحي المباشر أو المسجَّل مسبقًا قبل قراءة التفاصيل الفنية:
ملاحظة حول المشاكل المتعلّقة بالكاميرا
ربما لاحظت أن تغيير الإعدادات يستغرق بعض الوقت في الفيديو. كما هو الحال مع معظم المشاكل الأخرى التي قد تظهر لك، لا يرجع ذلك إلى أداء WebAssembly أو WebUSB، ولكن إلى كيفية تفاعل gPhoto2 مع الكاميرا المحدّدة التي تم اختيارها للعرض التوضيحي.
لا توفّر كاميرا Sony a6600 واجهة برمجة تطبيقات لضبط قيم مثل ISO أو فتحة العدسة أو سرعة الغالق مباشرةً، بل توفّر فقط أوامر لزيادتها أو خفضها بعدد محدّد من الخطوات. وما يزيد الأمر تعقيدًا هو أنّها لا تعرض قائمة بالقيم المسموح بها فعليًا، بل يبدو أنّ القائمة المعروضة مضمّنة في العديد من طُرز كاميرات Sony.
عند ضبط إحدى هذه القيم، ليس أمام gPhoto2 خيار آخر سوى:
- اتّخِذ خطوة (أو بضع خطوات) في اتجاه القيمة التي اخترتها.
- انتظِر قليلاً حتى تعدّل الكاميرا الإعدادات.
- اقرأ القيمة التي استقرت عليها الكاميرا فعليًا.
- يُرجى التحقّق من أنّ الخطوة الأخيرة لم تتخطَّ القيمة المطلوبة أو لم تلتف حول نهاية القائمة أو بدايتها.
- والتكرار.
قد يستغرق ذلك بعض الوقت، ولكن إذا كانت القيمة مسموحًا بها بالفعل من قِبل الكاميرا، فستصل إليها، وإذا لم تكن كذلك، فستتوقف عند أقرب قيمة مسموح بها.
من المرجّح أن تتضمّن الكاميرات الأخرى مجموعات مختلفة من الإعدادات وواجهات برمجة التطبيقات الأساسية والميزات الغريبة. يُرجى العِلم أنّ gPhoto2 هو مشروع مفتوح المصدر، ولا يمكن إجراء اختبار آلي أو يدوي لجميع طُرز الكاميرات المتاحة، لذا نرحّب دائمًا بتقارير المشاكل التفصيلية وطلبات السحب (ولكن يُرجى التأكّد من إعادة إنشاء المشاكل باستخدام برنامج gPhoto2 الرسمي أولاً).
ملاحظات مهمة حول التوافق مع عدّة منصات
للأسف، على نظام التشغيل Windows، يتم تخصيص برنامج تشغيل نظام لأي أجهزة "معروفة"، بما في ذلك كاميرات DSLR، وهو غير متوافق مع WebUSB. إذا أردت تجربة الإصدار التجريبي على نظام التشغيل Windows، عليك استخدام أداة مثل Zadig لتجاوز برنامج تشغيل كاميرا DSLR المتصلة إما بـ WinUSB أو libusb. هذه الطريقة مناسبة لي وللعديد من المستخدمين الآخرين، ولكن عليك استخدامها على مسؤوليتك الخاصة.
على نظام التشغيل Linux، ستحتاج على الأرجح إلى ضبط أذونات مخصّصة للسماح بالوصول إلى كاميرا DSLR عبر WebUSB، على الرغم من أنّ ذلك يعتمد على التوزيع الذي تستخدمه.
على نظامَي التشغيل macOS وAndroid، من المفترض أن يعمل الإصدار التجريبي تلقائيًا. إذا كنت تحاول استخدامها على هاتف Android، تأكَّد من التبديل إلى الوضع الأفقي لأنّني لم أبذل جهدًا كبيرًا في جعلها متجاوبة (نرحّب بطلبات السحب):
للحصول على دليل أكثر تفصيلاً حول استخدام WebUSB على عدّة منصات، يُرجى الاطّلاع على قسم "الاعتبارات الخاصة بالمنصة" في مقالة "إنشاء جهاز لواجهة WebUSB".
إضافة واجهة خلفية جديدة إلى libusb
والآن، لننتقل إلى التفاصيل الفنية. While it's possible to provide a shim API similar to libusb (this has been done by others before) and link other applications against it, this approach is error-prone and makes any further extension or maintenance harder. أردتُ تنفيذ الأمور بشكل صحيح، بطريقة يمكن أن تساهم في تحسين الإصدار الأصلي ودمجها في 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;
}
التعرّف على الأجهزة
Of course, before I can open any device, libusb needs to retrieve a list of available devices. يجب أن تنفّذ الخلفية هذه العملية من خلال معالج 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
ما يفعله هو:
- يتم استدعاء
poll()للتحقّق مما إذا تم الإبلاغ عن أي أحداث من خلال الخلفية حتى الآن. إذا كان هناك بعض، تتوقف الحلقة. بخلاف ذلك، سيتم عرض0على الفور من خلال تنفيذ Emscripten لـpoll(). - الاتصال بالرقم
emscripten_sleep(0)تستخدم هذه الدالة Asyncify وsetTimeout()في الخلفية، ويتم استخدامها هنا لإعادة التحكّم إلى حلقة معالجة الأحداث الرئيسية في المتصفّح. ويسمح ذلك للمتصفّح بمعالجة أي تفاعلات للمستخدم وأحداث الإدخال/الإخراج، بما في ذلك WebUSB. - تحقَّق مما إذا كانت المهلة المحدّدة قد انتهت، وإذا لم تنتهِ، واصِل التكرار.
كما هو مذكور في التعليق، لم يكن هذا الأسلوب مثاليًا، لأنّه استمر في حفظ واستعادة حزمة التنفيذ بالكامل باستخدام 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".
![لقطة شاشة تعرض نافذة Chrome مع فتح "أدوات مطوّري البرامج" على صفحة `testlibusb` يتم عرضها محليًا تُقيّم وحدة تحكّم "أدوات مطوري البرامج" السمة `navigator.usb.requestDevice({ filters: [] })` التي أدّت إلى ظهور طلب إذن، وتطلب حاليًا من المستخدم اختيار جهاز USB يجب مشاركته مع الصفحة. تم اختيار ILCE-6600 (كاميرا Sony) حاليًا.](https://web.developers.google.cn/static/articles/porting-libusb-to-webusb/image/screenshot-showing-chrom-b19a9e4e1b916.png?hl=ar)
![لقطة شاشة للخطوة التالية، مع بقاء "أدوات مطوّري البرامج" مفتوحة بعد اختيار الجهاز، قيّمت وحدة التحكّم تعبيرًا جديدًا `Module.callMain(['-v'])` نفّذ تطبيق `testlibusb` في وضع التفاصيل. تعرض النتيجة معلومات مفصّلة مختلفة عن كاميرا USB التي تم توصيلها سابقًا: الشركة المصنّعة Sony، والمنتج ILCE-6600، والرقم التسلسلي، والإعدادات، وما إلى ذلك.](https://web.developers.google.cn/static/articles/porting-libusb-to-webusb/image/screenshot-the-next-step-bd4e4c7f85be7.png?hl=ar)
قد لا يبدو ذلك كثيرًا، ولكن عند نقل المكتبات إلى نظام أساسي جديد، فإن الوصول إلى المرحلة التي تنتج فيها مخرجات صالحة لأول مرة أمر مثير للغاية!
استخدام المنفذ
كما ذكرنا أعلاه، يعتمد المنفذ على بعض ميزات Emscripten التي يجب تفعيلها حاليًا في مرحلة الربط في التطبيق. إذا أردت استخدام منفذ libusb هذا في تطبيقك، إليك ما عليك فعله:
- نزِّل أحدث إصدار من libusb إما كأرشيف كجزء من عملية الإنشاء أو أضِفه كوحدة فرعية في git في مشروعك.
- شغِّل
autoreconf -fivفي مجلدlibusb. - شغِّل
emconfigure ./configure –host=wasm32 –prefix=/some/installation/pathلتهيئة المشروع للتجميع على أنظمة أساسية متعددة وتحديد مسار لوضع العناصر التي تم إنشاؤها. - شغِّل
emmake make install. - وجِّه تطبيقك أو مكتبتك ذات المستوى الأعلى للبحث عن libusb ضمن المسار الذي اخترته سابقًا.
- أضِف العلامات التالية إلى وسيطات رابط تطبيقك:
--bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH.
تتضمّن المكتبة حاليًا بعض القيود:
- لا تتوفّر إمكانية إلغاء عملية النقل. هذا أحد قيود WebUSB، والذي ينبع بدوره من عدم توفّر ميزة إلغاء النقل من عدّة منصات في libusb نفسه.
- لا تتوفّر ميزة النقل المتزامن. من المفترض ألا يكون من الصعب إضافته باتّباع طريقة تنفيذ أوضاع النقل الحالية كأمثلة، ولكنّه أيضًا وضع نادر إلى حدّ ما ولم يكن لديّ أي أجهزة لاختباره، لذا تركته في الوقت الحالي كوضع غير متوافق. إذا كانت لديك هذه الأجهزة وأردت المساهمة في المكتبة، نرحّب بطلبات السحب.
- القيود المذكورة سابقًا على استخدام التطبيق من عدّة منصات تفرض أنظمة التشغيل هذه القيود، لذا لا يمكننا فعل الكثير هنا، باستثناء مطالبة المستخدمين بتجاوز برنامج التشغيل أو الأذونات. ومع ذلك، إذا كنت تنقل أجهزة HID أو أجهزة تسلسلية، يمكنك اتّباع مثال libusb ونقل مكتبة أخرى إلى واجهة برمجة تطبيقات Fugu أخرى. على سبيل المثال، يمكنك نقل مكتبة C hidapi إلى WebHID وتجنُّب هذه المشاكل المرتبطة بالوصول إلى USB من المستوى المنخفض تمامًا.
الخاتمة
في هذه المشاركة، أوضحتُ كيف يمكن نقل المكتبات ذات المستوى المنخفض، مثل libusb، إلى الويب باستخدام بعض حيل الدمج، وذلك بمساعدة واجهات برمجة التطبيقات Emscripten وAsyncify وFugu.
إنّ نقل هذه المكتبات الأساسية والمنخفضة المستوى والمستخدمة على نطاق واسع هو أمر مفيد بشكل خاص، لأنّه يتيح بدوره نقل المكتبات ذات المستوى الأعلى أو حتى التطبيقات الكاملة إلى الويب أيضًا. ويتيح ذلك تجارب كانت تقتصر في السابق على مستخدمي منصة واحدة أو منصتَين، لجميع أنواع الأجهزة وأنظمة التشغيل، ما يجعل هذه التجارب متاحة بمجرد النقر على رابط.
في المنشور التالي، سأشرح الخطوات المتعلّقة بإنشاء الإصدار التجريبي من gPhoto2 على الويب الذي لا يسترد معلومات الجهاز فحسب، بل يستخدم أيضًا ميزة النقل في libusb على نطاق واسع. في الوقت نفسه، آمل أن يكون مثال libusb ملهمًا لك وأن تجرّب الإصدار التجريبي أو تستخدم المكتبة نفسها أو ربما تنقل مكتبة أخرى شائعة الاستخدام إلى إحدى واجهات برمجة التطبيقات في Fugu أيضًا.