การพอร์ตแอปพลิเคชัน 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 ตรวจสอบให้แน่ใจว่าได้เปลี่ยนเป็นโหมดแนวนอนแล้ว เนื่องจากเราไม่ได้พยายามอย่างมากในการทำให้หน้าจอตอบสนองตามอุปกรณ์ (เรายินดีให้การประชาสัมพันธ์)

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

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

การเพิ่มแบ็กเอนด์ใหม่ไปยัง libusb

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

ช่องข้อมูลส่วนตัวมีประโยชน์เป็นอย่างน้อยในการจัดเก็บแฮนเดิลระบบปฏิบัติการให้กับสิ่งต่างๆ เหล่านั้น เนื่องจากหากไม่มีแฮนเดิล เราจะไม่ทราบว่าการดําเนินการใดมีผลกับรายการใดบ้าง ในการใช้งานเว็บ แฮนเดิลระบบปฏิบัติการจะเป็นออบเจ็กต์ WebUSB JavaScript ที่สำคัญ วิธีธรรมชาติในการแสดงและจัดเก็บอีเมลใน 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))) {}
};

API ของเว็บที่ไม่พร้อมกันในบริบท C แบบซิงโครนัส

ตอนนี้ถึงเวลาที่จะจัดการกับ WebUSB API ที่ไม่พร้อมกัน ซึ่ง libusb คาดว่าจะดำเนินการแบบพร้อมกัน สำหรับขั้นตอนนี้ ฉันสามารถใช้ Asyncify หรือกล่าวอย่างเจาะจงคือใช้การผสานรวม Embind ผ่าน val::await()

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

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

มีปัญหาบางประการกับวิธีการนี้บนเว็บ

อย่างแรก 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 สิ่งเดียวที่น่าสนใจที่นี่คือการแก้ไข Flags เฉพาะของ 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 ที่มี Shell เริ่มต้นของ 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 เหล่านั้นด้วยตนเองด้วยความช่วยเหลือของ DevTools

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

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

บทสรุป

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

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

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