การพอร์ตแอปพลิเคชัน USB ไปยังเว็บ ส่วนที่ 1: libusb

ดูวิธีพอร์ตโค้ดที่โต้ตอบกับอุปกรณ์ภายนอกไปยังเว็บด้วย WebAssembly และ Fugu API

ในโพสต์ก่อนหน้า เราได้แสดงวิธีพอร์ตแอปที่ใช้ File System API ไปยังเว็บด้วย File System Access API, WebAssembly และ Asyncify ตอนนี้เราขอพูดถึงเรื่องเดิมต่อเรื่องการผสานรวม Fugu API กับ WebAssembly และการพอร์ตแอปไปยังเว็บโดยไม่สูญเสียฟีเจอร์สําคัญ

เราจะแสดงวิธีพอร์ตแอปที่สื่อสารกับอุปกรณ์ USB ไปยังเว็บโดยการพอร์ต libusb ซึ่งเป็นไลบรารี USB ที่ได้รับความนิยมซึ่งเขียนด้วย C ไปยัง WebAssembly (ผ่าน Emscripten), Asyncify และ WebUSB

ก่อนอื่นมาดูการสาธิตกัน

สิ่งที่สำคัญที่สุดเมื่อพอร์ตไลบรารีคือการเลือกเดโมที่เหมาะสม ซึ่งจะแสดงความสามารถของไลบรารีที่พอร์ต ซึ่งจะช่วยให้คุณทดสอบได้หลายวิธีและดึงดูดสายตาในเวลาเดียวกัน

ไอเดียที่ฉันเลือกคือรีโมตคอนโทรล DSLR โดยเฉพาะอย่างยิ่ง โปรเจ็กต์โอเพนซอร์สอย่าง gPhoto2 อยู่ในแวดวงนี้มาอย่างยาวนานพอที่จะทำการวิศวกรรมย้อนกลับและรองรับกล้องดิจิทัลที่หลากหลาย โดยรองรับโปรโตคอลหลายรายการ แต่สิ่งที่เราสนใจมากที่สุดคือการรองรับ USB ซึ่งดำเนินการผ่าน libusb

เราจะอธิบายขั้นตอนการสร้างการสาธิตนี้ออกเป็น 2 ส่วน ในบล็อกโพสต์นี้ เราจะอธิบายวิธีพอร์ต libusb เอง และเคล็ดลับที่อาจจําเป็นในการพอร์ตไลบรารียอดนิยมอื่นๆ ไปยัง Fugu API ในโพสต์ที่ 2 เราจะอธิบายรายละเอียดเกี่ยวกับการพอร์ตและผสานรวม gPhoto2

สุดท้ายนี้ เราได้รับเว็บแอปพลิเคชันที่ใช้งานได้ซึ่งแสดงตัวอย่างฟีดสดจาก DSLR และควบคุมการตั้งค่าผ่าน USB ได้ คุณสามารถดูการสาธิตแบบสดหรือแบบบันทึกไว้ล่วงหน้าก่อนอ่านรายละเอียดทางเทคนิคได้

การสาธิตที่ทำงานบนแล็ปท็อปที่เชื่อมต่อกับกล้อง Sony

หมายเหตุเกี่ยวกับข้อบกพร่องเฉพาะกล้อง

คุณอาจสังเกตเห็นว่าวิดีโอใช้เวลาสักครู่ในการเปลี่ยนการตั้งค่า เช่นเดียวกับปัญหาอื่นๆ ส่วนใหญ่ที่คุณอาจพบ ปัญหานี้ไม่ได้เกิดจากประสิทธิภาพของ WebAssembly หรือ WebUSB แต่เกิดจากวิธีที่ gPhoto2 โต้ตอบกับกล้องที่เลือกไว้สำหรับเดโม

Sony a6600 ไม่ได้แสดง API เพื่อตั้งค่าต่างๆ เช่น ISO, รูรับแสง หรือความเร็วชัตเตอร์โดยตรง แต่จะให้เฉพาะคำสั่งเพื่อเพิ่มหรือลดค่าตามจำนวนขั้นตอนที่ระบุ ปัญหายิ่งซับซ้อนขึ้นเมื่อคำสั่งนี้ไม่แสดงรายการค่าที่รองรับจริงด้วย ดูเหมือนว่ารายการที่แสดงผลจะกำหนดไว้ล่วงหน้าในกล้อง Sony หลายรุ่น

เมื่อตั้งค่าค่าใดค่าหนึ่ง gPhoto2 จะไม่มีทางเลือกอื่นนอกจากต้องดำเนินการต่อไปนี้

  1. เพิ่มค่า (1 หรือ 2 ค่า) ในทิศทางของค่าที่เลือก
  2. รอสักครู่เพื่อให้กล้องอัปเดตการตั้งค่า
  3. อ่านค่าที่กล้องจับได้
  4. ตรวจสอบว่าขั้นตอนสุดท้ายไม่ได้ข้ามค่าที่ต้องการหรือตัดไปไว้ที่ท้ายหรือต้นรายการ
  5. ทำซ้ำ

การดำเนินการนี้อาจใช้เวลาสักครู่ แต่หากกล้องรองรับค่าดังกล่าวจริง กล้องก็จะปรับค่าให้ แต่ถ้าไม่ กล้องจะหยุดที่ค่าที่รองรับซึ่งใกล้เคียงที่สุด

กล้องอื่นๆ มีแนวโน้มที่จะใช้การตั้งค่า API พื้นฐาน และลักษณะการทำงานที่ต่างกัน โปรดทราบว่า gPhoto2 เป็นโปรเจ็กต์โอเพนซอร์ส และการทดสอบกล้องทุกรุ่นที่มีอยู่ไม่ว่าจะแบบอัตโนมัติหรือด้วยตนเองนั้นไม่สามารถทำได้ เราจึงยินดีรับรายงานปัญหาและ PR โดยละเอียดเสมอ (แต่อย่าลืมจำลองปัญหาด้วยไคลเอ็นต์ gPhoto2 อย่างเป็นทางการก่อน)

หมายเหตุสำคัญเกี่ยวกับการเข้ากันได้ข้ามแพลตฟอร์ม

ขออภัย ใน Windows อุปกรณ์ที่ "รู้จักดี" ซึ่งรวมถึงกล้อง DSLR จะได้รับการกำหนดไดรเวอร์ระบบที่ไม่เข้ากันได้กับ WebUSB หากต้องการลองใช้เวอร์ชันเดโมใน Windows คุณต้องใช้เครื่องมืออย่าง Zadig เพื่อลบล้างไดรเวอร์ของ DSLR ที่เชื่อมต่อเป็น WinUSB หรือ libusb แนวทางนี้ใช้ได้ผลกับเราและผู้ใช้อีกหลายราย แต่คุณควรใช้วิธีนี้โดยยอมรับความเสี่ยงด้วยตนเอง

ใน Linux คุณอาจต้องตั้งค่าสิทธิ์ที่กำหนดเองเพื่ออนุญาตให้เข้าถึง DSLR ผ่าน WebUSB ทั้งนี้ขึ้นอยู่กับการแจกจ่ายของคุณ

ใน macOS และ Android เดโมควรใช้งานได้ทันที หากคุณกำลังลองใช้บนโทรศัพท์ Android อย่าลืมเปลี่ยนเป็นโหมดแนวนอนเนื่องจากเราไม่ได้พยายามมากนักที่จะทำให้แอปนี้ปรับเปลี่ยนขนาดได้ (ยินดีรับ PR):

โทรศัพท์ Android เชื่อมต่อกับกล้อง Canon ผ่านสาย USB-C
การสาธิตเดียวกันที่ทำงานบนโทรศัพท์ Android รูปภาพโดย Surma

ดูคำแนะนำเชิงลึกเพิ่มเติมเกี่ยวกับการใช้งาน WebUSB แบบข้ามแพลตฟอร์มได้ที่ส่วน"ข้อควรพิจารณาเฉพาะแพลตฟอร์ม" ของ "การสร้างอุปกรณ์สำหรับ WebUSB"

การเพิ่มแบ็กเอนด์ใหม่ลงใน libusb

มาพูดถึงรายละเอียดทางเทคนิคกัน แม้ว่าจะระบุ API ชิมคล้ายกับ libusb ได้ (ซึ่งคนอื่นเคยทำมาแล้ว) และลิงก์แอปพลิเคชันอื่นๆ กับ API ดังกล่าว แต่แนวทางนี้มักเกิดข้อผิดพลาดและทำให้ส่วนขยายหรือการบำรุงรักษาเพิ่มเติมทำได้ยากขึ้น เราต้องการทําให้ถูกต้องในลักษณะที่อาจมีการมีส่วนร่วมกลับไปยัง upstream และผสานรวมเข้ากับ libusb ในอนาคต

แต่โชคดีที่libusb README ระบุว่า

"libusb ได้รับการแยกออกมาเป็นการภายในในลักษณะที่หวังว่าจะพอร์ตไปยังระบบปฏิบัติการอื่นๆ ได้ โปรดดูข้อมูลเพิ่มเติมในไฟล์ PORTING

libusb มีโครงสร้างในลักษณะที่แยก API สาธารณะออกจาก "แบ็กเอนด์" แบ็กเอนด์ดังกล่าวมีหน้าที่แสดงรายการ เปิด ปิด และสื่อสารกับอุปกรณ์ผ่าน API ระดับต่ำของระบบปฏิบัติการ นี่เป็นวิธีที่ 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))) {}
};

Async Web API ในบริบท C แบบเรียลไทม์

ตอนนี้จําเป็นต้องใช้วิธีจัดการ WebUSB API แบบแอซิงค์เมื่อ libusb คาดหวังการดําเนินการแบบซิงค์ ในกรณีนี้ เราอาจใช้ Asyncify หรืออย่างเจาะจงกว่านั้นคือใช้การผสานรวม Embind ผ่าน val::await()

นอกจากนี้ เรายังต้องการจัดการข้อผิดพลาดของ WebUSB อย่างถูกต้องและแปลงเป็นรหัสข้อผิดพลาดของ libusb แต่ปัจจุบัน Embind ไม่มีวิธีจัดการข้อยกเว้นของ JavaScript หรือการปฏิเสธ Promise จากฝั่ง C++ ปัญหานี้แก้ไขได้โดยการจับการปฏิเสธในฝั่ง JavaScript และแปลงผลลัพธ์เป็นออบเจ็กต์ { error, value } ที่ตอนนี้สามารถแยกวิเคราะห์จากฝั่ง C++ ได้อย่างปลอดภัย เราทําเช่นนี้โดยใช้มาโคร EM_JS และ API Emval.to{Handle, Value} ร่วมกัน

EM_JS(EM_VAL, em_promise_catch_impl, (EM_VAL handle), {
  let promise = Emval.toValue(handle);
  promise = promise.then(
    value => ({error : 0, value}),
    error => {
      const ERROR_CODES = {
        // LIBUSB_ERROR_IO
        NetworkError : -1,
        // LIBUSB_ERROR_INVALID_PARAM
        DataError : -2,
        TypeMismatchError : -2,
        IndexSizeError : -2,
        // LIBUSB_ERROR_ACCESS
        SecurityError : -3,
        …
      };
      console.error(error);
      let errorCode = -99; // LIBUSB_ERROR_OTHER
      if (error instanceof DOMException)
      {
        errorCode = ERROR_CODES[error.name] ?? errorCode;
      }
      else if (error instanceof RangeError || error instanceof TypeError)
      {
        errorCode = -2; // LIBUSB_ERROR_INVALID_PARAM
      }
      return {error: errorCode, value: undefined};
    }
  );
  return Emval.toHandle(promise);
});

val em_promise_catch(val &&promise) {
  EM_VAL handle = promise.as_handle();
  handle = em_promise_catch_impl(handle);
  return val::take_ownership(handle);
}

// C++ struct representation for {value, error} object from above
// (performs conversion in the constructor).
struct promise_result {
  libusb_error error;
  val value;

  promise_result(val &&result)
      : error(static_cast<libusb_error>(result["error"].as<int>())),
        value(result["value"]) {}

  // C++ counterpart of the promise helper above that takes a promise, catches
  // its error, converts to a libusb status and returns the whole thing as
  // `promise_result` struct for easier handling.
  static promise_result await(val &&promise) {
    promise = em_promise_catch(std::move(promise));
    return {promise.await()};
  }
};

ตอนนี้ฉันสามารถใช้ promise_result::await() ใน Promise ใดก็ได้ที่แสดงผลจากการดำเนินการ WebUSB และตรวจสอบฟิลด์ error และ value แยกกัน

ตัวอย่างเช่น การดึงข้อมูล val ที่แสดงถึง USBDevice จาก libusb_device_handle การเรียกเมธอด open() ของ val รอผลลัพธ์ และแสดงรหัสข้อผิดพลาดเป็นรหัสสถานะ 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 ที่เชื่อมต่อทั้งหมดบนเว็บด้วยเหตุผลด้านความปลอดภัย ซึ่งต่างจากแพลตฟอร์มอื่นๆ แต่ระบบจะแบ่งขั้นตอนออกเป็น 2 ส่วน ขั้นแรก เว็บแอปพลิเคชันจะขออุปกรณ์ที่มีพร็อพเพอร์ตี้ที่เฉพาะเจาะจงผ่าน 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 ในลักษณะที่คล้ายกับที่แสดงไว้ด้านบน ยังมีแฮ็กที่น่าสนใจอีก 2-3 อย่างในโค้ดการจัดการการโอนข้อมูล แต่รายละเอียดการติดตั้งใช้งานเหล่านั้นมีความสำคัญน้อยกว่าสำหรับวัตถุประสงค์ของบทความนี้ อย่าลืมตรวจสอบโค้ดและความคิดเห็นใน GitHub หากสนใจ

การย้ายข้อมูลลูปเหตุการณ์ไปยังเว็บ

อีกสิ่งหนึ่งที่ฉันอยากพูดถึงเกี่ยวกับพอร์ต libusb คือการจัดการเหตุการณ์ ดังที่อธิบายไว้ในบทความก่อนหน้า API ส่วนใหญ่ในภาษาของระบบ เช่น C เป็นแบบซิงค์ และการจัดการเหตุการณ์ก็ไม่มีข้อยกเว้น โดยปกติแล้วจะใช้ผ่านลูปที่ไม่มีที่สิ้นสุดซึ่ง "โพล" (พยายามอ่านข้อมูลหรือบล็อกการดำเนินการจนกว่าจะมีข้อมูลบางส่วน) จากชุดแหล่งข้อมูล I/O ภายนอก และเมื่อแหล่งข้อมูลอย่างน้อย 1 แหล่งตอบกลับ ก็จะส่งข้อมูลนั้นไปยังตัวแฮนเดิลที่เกี่ยวข้องเป็นเหตุการณ์ เมื่อตัวแฮนเดิลทำงานเสร็จแล้ว การควบคุมจะกลับไปที่ลูปและหยุดชั่วคราวเพื่อทำการสำรวจอีกครั้ง

แนวทางนี้บนเว็บมีปัญหาอยู่ 2 อย่าง

ประการแรก WebUSB ไม่ได้และไม่สามารถแสดงแฮนเดิลดิบของอุปกรณ์ที่อยู่เบื้องหลัง ดังนั้นการสำรวจโดยตรงจึงไม่ใช่ตัวเลือก ประการที่ 2 คือ libusb ใช้ eventfd และ pipe API สําหรับเหตุการณ์อื่นๆ รวมถึงการจัดการการโอนในระบบปฏิบัติการที่ไม่มีแฮนเดิลอุปกรณ์ดิบ แต่ปัจจุบัน Emscripten ไม่รองรับ eventfd และแม้ว่า pipe จะรองรับ แต่ปัจจุบันไม่เป็นไปตามข้อกําหนดและรอเหตุการณ์ไม่ได้

สุดท้าย ปัญหาที่ใหญ่ที่สุดคือเว็บมีลูปเหตุการณ์ของตัวเอง ลูปเหตุการณ์ส่วนกลางนี้ใช้สำหรับการดำเนินการ I/O ภายนอก (รวมถึง fetch(), ตัวจับเวลา หรือในกรณีนี้ WebUSB) และจะเรียกใช้ตัวแฮนเดิลเหตุการณ์หรือ Promise เมื่อการดำเนินการที่เกี่ยวข้องเสร็จสิ้น การดำเนินการกับลูปเหตุการณ์แบบไม่มีที่สิ้นสุดที่ฝังอยู่อีกรายการหนึ่งจะบล็อกไม่ให้ลูปเหตุการณ์ของเบราว์เซอร์ทำงานต่อ ซึ่งหมายความว่า UI จะไม่ตอบสนองและโค้ดจะไม่ได้รับแจ้งสำหรับเหตุการณ์ I/O เดียวกันกับที่รออยู่ ซึ่งมักส่งผลให้เกิดปัญหาการล็อก ซึ่งก็เกิดขึ้นเมื่อฉันพยายามใช้ libusb ในเวอร์ชันเดโมด้วย หน้าเว็บค้าง

เช่นเดียวกับ I/O แบบบล็อกอื่นๆ หากต้องการพอร์ตลูปเหตุการณ์ดังกล่าวไปยังเว็บ นักพัฒนาซอฟต์แวร์ต้องหาวิธีเรียกใช้ลูปเหล่านั้นโดยไม่บล็อกเธรดหลัก วิธีหนึ่งคือเปลี่ยนโครงสร้างภายในแอปพลิเคชันเพื่อจัดการเหตุการณ์ I/O ในเธรดแยกต่างหากและส่งผลลัพธ์กลับไปยังเธรดหลัก อีกวิธีหนึ่งคือการใช้ Asyncify เพื่อหยุดการวนซ้ำชั่วคราวและรอเหตุการณ์ในลักษณะที่ไม่บล็อก

เราไม่ได้ต้องการทําการเปลี่ยนแปลงที่สําคัญกับ libusb หรือ gPhoto2 และเราได้ใช้ Asyncify สําหรับการผสานรวม Promise อยู่แล้ว เราจึงเลือกเส้นทางนี้ ในการจําลองตัวแปรการบล็อกของ poll() สําหรับการพิสูจน์แนวคิดขั้นต้น เราใช้ลูปดังที่แสดงด้านล่าง

#ifdef __EMSCRIPTEN__
  // TODO: optimize this. Right now it will keep unwinding-rewinding the stack
  // on each short sleep until an event comes or the timeout expires.
  // We should probably create an actual separate thread that does signaling
  // or come up with a custom event mechanism to report events from
  // `usbi_signal_event` and process them here.
  double until_time = emscripten_get_now() + timeout_ms;
  do {
    // Emscripten `poll` ignores timeout param, but pass 0 explicitly just
    // in case.
    num_ready = poll(fds, nfds, 0);
    if (num_ready != 0) break;
    // Yield to the browser event loop to handle events.
    emscripten_sleep(0);
  } while (emscripten_get_now() < until_time);
#else
  num_ready = poll(fds, nfds, timeout_ms);
#endif

การดำเนินการของฟีเจอร์มีดังนี้

  1. เรียก poll() เพื่อตรวจสอบว่าแบ็กเอนด์รายงานเหตุการณ์ใดไปแล้วหรือยัง หากมี ลูปจะหยุด มิเช่นนั้น การใช้งาน poll() ของ Emscripten จะแสดงผล 0 ทันที
  2. การโทร emscripten_sleep(0) ฟังก์ชันนี้ใช้ Asyncify และ setTimeout() อยู่เบื้องหลัง และนำมาใช้ที่นี่เพื่อส่งการควบคุมกลับไปยังลูปเหตุการณ์หลักของเบราว์เซอร์ ซึ่งช่วยให้เบราว์เซอร์จัดการการโต้ตอบของผู้ใช้และเหตุการณ์ I/O รวมถึง WebUSB ได้
  3. ตรวจสอบว่าระยะหมดเวลาที่ระบุหมดอายุแล้วหรือยัง หากยังไม่หมดอายุ ให้ทำซ้ำตามรอบ

ดังที่ความคิดเห็นได้กล่าวไว้ แนวทางนี้ไม่เหมาะที่สุด เนื่องจากจะบันทึกและกู้คืนกองซ้อนการเรียกทั้งหมดด้วย Asyncify อยู่เรื่อยๆ แม้ว่าจะยังไม่มีเหตุการณ์ USB ที่ต้องจัดการ (ซึ่งส่วนใหญ่เป็นเช่นนั้น) และเนื่องจาก setTimeout() เองมีระยะเวลาขั้นต่ำ 4 มิลลิวินาทีในเบราว์เซอร์สมัยใหม่ แต่ก็ยังทำงานได้ดีพอที่จะสตรีมแบบสดด้วยอัตราเฟรม 13-14 FPS จาก DSLR ในการพิสูจน์แนวคิด

ต่อมา เราตัดสินใจปรับปรุงโดยใช้ระบบเหตุการณ์เบราว์เซอร์ การใช้งานนี้สามารถปรับปรุงให้ดียิ่งขึ้นได้หลายวิธี แต่ตอนนี้เราเลือกที่จะส่งเหตุการณ์ที่กําหนดเองในออบเจ็กต์ส่วนกลางโดยตรง โดยไม่เชื่อมโยงกับโครงสร้างข้อมูล libusb ที่เฉพาะเจาะจง เราได้ดำเนินการดังกล่าวผ่านกลไกการรอและการแจ้งเตือนตามมาโคร EM_ASYNC_JS ดังนี้

EM_JS(void, em_libusb_notify, (void), {
  dispatchEvent(new Event("em-libusb"));
});

EM_ASYNC_JS(int, em_libusb_wait, (int timeout), {
  let onEvent, timeoutId;

  try {
    return await new Promise(resolve => {
      onEvent = () => resolve(0);
      addEventListener('em-libusb', onEvent);

      timeoutId = setTimeout(resolve, timeout, -1);
    });
  } finally {
    removeEventListener('em-libusb', onEvent);
    clearTimeout(timeoutId);
  }
});

ระบบจะใช้ฟังก์ชัน em_libusb_notify() ทุกครั้งที่ libusb พยายามรายงานเหตุการณ์ เช่น การโอนข้อมูลเสร็จสมบูรณ์

void usbi_signal_event(usbi_event_t *event)
{
  uint64_t dummy = 1;
  ssize_t r;

  r = write(EVENT_WRITE_FD(event), &dummy, sizeof(dummy));
  if (r != sizeof(dummy))
    usbi_warn(NULL, "event write failed");
#ifdef __EMSCRIPTEN__
  em_libusb_notify();
#endif
}

ในระหว่างนี้ ส่วน em_libusb_wait() จะใช้เพื่อ "ตื่นขึ้น" จากการหยุดทำงานแบบ Asyncify เมื่อได้รับเหตุการณ์ em-libusb หรือเมื่อหมดเวลาตามกำหนด

double until_time = emscripten_get_now() + timeout_ms;
for (;;) {
  // Emscripten `poll` ignores timeout param, but pass 0 explicitly just
  // in case.
  num_ready = poll(fds, nfds, 0);
  if (num_ready != 0) break;
  int timeout = until_time - emscripten_get_now();
  if (timeout <= 0) break;
  int result = em_libusb_wait(timeout);
  if (result != 0) break;
}

เนื่องจากมีการลดการหยุดทำงานชั่วคราวและการตื่นขึ้นอย่างมาก กลไกนี้จึงแก้ไขปัญหาประสิทธิภาพของการใช้งาน emscripten_sleep() ก่อนหน้านี้ และเพิ่มปริมาณข้อมูลของเดโม DSLR จาก 13-14 FPS เป็น 30 FPS ขึ้นไปอย่างสม่ำเสมอ ซึ่งเพียงพอสำหรับฟีดการถ่ายทอดสดที่ราบรื่น

สร้างระบบและการทดสอบครั้งแรก

หลังจากแบ็กเอนด์เสร็จแล้ว เราต้องเพิ่มลงใน Makefile.am และ configure.ac สิ่งที่น่าสนใจเพียงอย่างเดียวในนี้คือการปรับแต่ง Flag สำหรับ 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

ประการที่ 2 เนื่องจากฉันใช้ Embind และ Asyncify จึงต้องเปิดใช้ฟีเจอร์เหล่านั้น (--bind -s ASYNCIFY) รวมถึงอนุญาตให้หน่วยความจําเพิ่มขนาดแบบไดนามิก (-s ALLOW_MEMORY_GROWTH) ผ่านพารามิเตอร์ linker แต่ไลบรารีไม่สามารถรายงาน Flag เหล่านั้นไปยังตัวลิงก์ได้ ดังนั้นแอปพลิเคชันทุกตัวที่ใช้พอร์ต libusb นี้จะต้องเพิ่ม Flag เดียวกันนี้ลงในการกำหนดค่าการสร้างด้วย

สุดท้าย ดังที่ได้กล่าวไว้ก่อนหน้านี้ WebUSB กำหนดให้ต้องดำเนินการแจกแจงอุปกรณ์ผ่านท่าทางสัมผัสของผู้ใช้ ตัวอย่างและการทดสอบ libusb จะถือว่าสามารถแจกแจงอุปกรณ์ได้เมื่อเริ่มต้น และดำเนินการไม่สำเร็จพร้อมแสดงข้อผิดพลาดหากไม่มีการเปลี่ยนแปลง แต่ต้องปิดใช้การเรียกใช้อัตโนมัติ (-s INVOKE_RUN=0) และแสดงเมธอด callMain() ด้วยตนเอง (-s EXPORTED_RUNTIME_METHODS=...)

เมื่อดำเนินการทั้งหมดเสร็จแล้ว ฉันสามารถแสดงไฟล์ที่สร้างขึ้นด้วยเว็บเซิร์ฟเวอร์แบบคงที่ เริ่มต้น WebUSB และเรียกใช้ไฟล์ HTML ที่เรียกใช้งานได้ด้วยตนเองด้วยความช่วยเหลือของเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์

ภาพหน้าจอแสดงหน้าต่าง Chrome ที่มีเครื่องมือสำหรับนักพัฒนาเว็บเปิดอยู่ในหน้า `testlibusb` ที่ให้บริการในเครื่อง คอนโซล DevTools กำลังประเมิน `navigator.usb.requestDevice({ filters: [] })` ซึ่งทริกเกอร์ข้อความแจ้งสิทธิ์และขณะนี้กำลังขอให้ผู้ใช้เลือกอุปกรณ์ USB ที่ควรแชร์กับหน้าเว็บ ขณะนี้ระบบเลือก ILCE-6600 (กล้อง Sony) ไว้

ภาพหน้าจอของขั้นตอนถัดไปโดยที่เครื่องมือสำหรับนักพัฒนาเว็บยังคงเปิดอยู่ หลังจากเลือกอุปกรณ์แล้ว คอนโซลจะประเมินนิพจน์ใหม่ `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. เพิ่ม Flag ต่อไปนี้ลงในอาร์กิวเมนต์ลิงก์ของแอปพลิเคชัน --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH

ขณะนี้คลังเพลงมีข้อจำกัดบางอย่างดังนี้

  • ไม่รองรับการยกเลิกการโอน ข้อจำกัดนี้เกิดจาก WebUSB ซึ่งก็เกิดจากข้อจำกัดของ libusb เองที่ไม่มีการยกเลิกการโอนข้ามแพลตฟอร์ม
  • ไม่รองรับการโอนแบบ Isochronous การเพิ่มโหมดนี้ไม่ยาก โดยทำตามการใช้โหมดการโอนที่มีอยู่เป็นตัวอย่าง แต่โหมดนี้ค่อนข้างหายากและเราไม่มีอุปกรณ์ที่จะทดสอบด้วย ดังนั้นตอนนี้จึงยังไม่รองรับ หากคุณมีอุปกรณ์ดังกล่าวและต้องการมีส่วนร่วมในคลัง เรายินดีรับ PR
  • ข้อจํากัดแบบข้ามแพลตฟอร์มที่กล่าวถึงก่อนหน้านี้ ข้อจำกัดเหล่านี้มาจากระบบปฏิบัติการ เราจึงทำอะไรได้เพียงเล็กน้อย ยกเว้นขอให้ผู้ใช้ลบล้างไดรเวอร์หรือสิทธิ์ อย่างไรก็ตาม หากคุณพอร์ตอุปกรณ์ HID หรือพอร์ตอนุกรม ให้ทำตามตัวอย่าง libusb และพอร์ตไลบรารีอื่นๆ ไปยัง Fugu API อื่น เช่น คุณอาจพอร์ตไลบรารี C hidapi ไปยัง WebHID และหลีกเลี่ยงปัญหาเหล่านั้นซึ่งเกี่ยวข้องกับการเข้าถึง USB ระดับต่ำไปเลย

บทสรุป

ในโพสต์นี้ เราจะแสดงวิธีพอร์ตไลบรารีระดับต่ำอย่าง libusb ไปยังเว็บด้วยความช่วยเหลือจาก Emscripten, Asyncify และ Fugu API ด้วยเทคนิคการผสานรวม 2-3 อย่าง

การพอร์ตไลบรารีระดับต่ำที่สำคัญและใช้กันอย่างแพร่หลายเช่นนี้ให้ผลลัพธ์ที่คุ้มค่าอย่างยิ่ง เนื่องจากจะช่วยให้คุณนําไลบรารีระดับสูงขึ้นหรือแม้แต่ทั้งแอปพลิเคชันไปใช้กับเว็บได้ด้วย การดำเนินการนี้เปิดประสบการณ์การใช้งานที่เคยจำกัดไว้สำหรับผู้ใช้แพลตฟอร์ม 1-2 แพลตฟอร์มให้พร้อมใช้งานในอุปกรณ์และระบบปฏิบัติการทุกประเภท เพียงแค่คลิกลิงก์เดียว

ในโพสต์ถัดไป เราจะอธิบายขั้นตอนการสร้างเว็บเดโม gPhoto2 ซึ่งไม่เพียงแต่จะดึงข้อมูลอุปกรณ์เท่านั้น แต่ยังใช้ฟีเจอร์การโอนของ libusb อย่างกว้างขวางด้วย ในระหว่างนี้ เราหวังว่าตัวอย่าง libusb จะสร้างแรงบันดาลใจให้คุณและคุณจะลองใช้เดโม เล่นกับไลบรารีเอง หรืออาจพอร์ตไลบรารีอื่นที่ใช้กันอย่างแพร่หลายไปยัง Fugu API รายการใดรายการหนึ่งด้วย