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

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

ในโพสต์ก่อนหน้า ฉันได้แสดงวิธีพอร์ตแอปที่ใช้ 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. ก้าวไปข้างหน้า (หรือ 2-3 ก้าว) ในทิศทางของค่านิยมที่เลือก
  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

คราวนี้มาดูรายละเอียดทางเทคนิคกันบ้าง แม้ว่าจะสามารถจัดเตรียม Shim API ที่คล้ายกับ libusb (ซึ่งเคยมีคนทำมาก่อน) และลิงก์แอปพลิเคชันอื่นๆ กับ API ดังกล่าวได้ แต่วิธีนี้อาจทำให้เกิดข้อผิดพลาดและทำให้การขยายหรือการบำรุงรักษาเพิ่มเติมทำได้ยากขึ้น ผมต้องการทำสิ่งต่างๆ อย่างถูกต้องในลักษณะที่อาจมีส่วนร่วมกับอัปสตรีมและผสานรวมเข้ากับ 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++ เท่านั้น ดังนั้นฉันจึงเลือกใช้ 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(), การรอผลลัพธ์ และการแสดงรหัสข้อผิดพลาดเป็นรหัสสถานะ 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 อย่างไรก็ตาม การแสดงข้อความแจ้งสิทธิ์พร้อมรายการอุปกรณ์ที่เชื่อมต่อถือเป็นการดำเนินการที่มีความละเอียดอ่อน และต้องทริกเกอร์โดยการโต้ตอบของผู้ใช้ (เช่น การคลิกปุ่มในหน้าเว็บ) ไม่เช่นนั้นระบบจะแสดงผลเป็น Promise ที่ถูกปฏิเสธเสมอ แอปพลิเคชัน 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 ใช้ API eventfd และ pipe สำหรับเหตุการณ์อื่นๆ รวมถึงการจัดการการโอนในระบบปฏิบัติการที่ไม่มีแฮนเดิลอุปกรณ์ดิบ แต่ปัจจุบัน 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 ส่วนที่น่าสนใจเพียงอย่างเดียวคือการแก้ไขแฟล็กเฉพาะของ 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 แต่ไลบรารีไม่มีวิธีรายงานแฟล็กเหล่านั้นไปยัง Linker ดังนั้นแอปพลิเคชันทุกแอปที่ใช้พอร์ต libusb นี้จะต้องเพิ่มแฟล็ก Linker เดียวกันลงในการกำหนดค่าบิลด์ด้วย

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

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

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

ภาพหน้าจอของขั้นตอนถัดไป โดยที่เครื่องมือสำหรับนักพัฒนาเว็บยังคงเปิดอยู่ หลังจากเลือกอุปกรณ์แล้ว Console ได้ประเมินนิพจน์ใหม่ `Module.callMain(['-v'])` ซึ่งเรียกใช้แอป `testlibusb` ในโหมดรายละเอียด เอาต์พุตจะแสดงข้อมูลโดยละเอียดต่างๆ เกี่ยวกับกล้อง USB ที่เชื่อมต่อก่อนหน้านี้ เช่น ผู้ผลิต Sony, ผลิตภัณฑ์ ILCE-6600, หมายเลขซีเรียล, การกำหนดค่า เป็นต้น

แม้จะดูเหมือนไม่มีอะไรมาก แต่เมื่อพอร์ตไลบรารีไปยังแพลตฟอร์มใหม่ การไปถึงขั้นตอนที่สร้างเอาต์พุตที่ถูกต้องเป็นครั้งแรกนั้นน่าตื่นเต้นมาก

การใช้พอร์ต

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

  1. ดาวน์โหลด libusb เวอร์ชันล่าสุดเป็นที่เก็บถาวรซึ่งเป็นส่วนหนึ่งของบิลด์ หรือเพิ่มเป็นโมดูลย่อยของ Git ในโปรเจ็กต์
  2. เรียกใช้ autoreconf -fiv ในโฟลเดอร์ libusb
  3. เรียกใช้ emconfigure ./configure –host=wasm32 –prefix=/some/installation/path เพื่อเริ่มต้นโปรเจ็กต์สำหรับการคอมไพล์แบบข้ามระบบและตั้งค่าเส้นทางที่คุณต้องการวางอาร์ติแฟกต์ที่สร้างขึ้น
  4. วิ่ง emmake make install
  5. กำหนดให้แอปพลิเคชันหรือไลบรารีระดับสูงกว่าค้นหา libusb ในเส้นทางที่เลือกไว้ก่อนหน้านี้
  6. เพิ่มแฟล็กต่อไปนี้ลงในอาร์กิวเมนต์ลิงก์ของแอปพลิเคชัน: --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH

ปัจจุบันไลบรารีมีข้อจำกัดบางประการ ดังนี้

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

บทสรุป

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

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

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