การพอร์ตแอปพลิเคชัน 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

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

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

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

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

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

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

บทสรุป

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

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

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