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

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

โชคดีที่ libusb README กล่าวว่า:

"libusb จัดทำขึ้นเป็นการภายในในลักษณะที่มีความเป็นไปได้ว่าจะสามารถนำไปใช้กับระบบปฏิบัติการอื่นๆ ได้ โปรดดูข้อมูลเพิ่มเติมในไฟล์การย้าย"

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

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

วิธีการนี้มีปัญหา 2-3 ข้อในเว็บ

ข้อแรก WebUSB จะไม่แสดงและไม่สามารถแสดงแฮนเดิลดิบของอุปกรณ์ที่สำคัญ ดังนั้นการสำรวจอุปกรณ์โดยตรงจึงไม่ใช่ทางเลือก ประการที่ 2 libusb ใช้ API ของ eventfd และ pipe สำหรับเหตุการณ์อื่นๆ เช่นเดียวกับการจัดการการโอนในระบบปฏิบัติการที่ไม่มีแฮนเดิลอุปกรณ์ซึ่งเป็นข้อมูลดิบ แต่ขณะนี้ eventfd ยังไม่รองรับใน Emscripten และ 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() เพื่อ "ตื่น" จากสลีปแบบอะซิงโครนัสเมื่อได้รับเหตุการณ์ 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 ที่มี Shell เริ่มต้นของ Emscripten ซึ่งดูแลการโหลดและเริ่มต้น JavaScript และ WebAssembly

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

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

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

ภาพหน้าจอแสดงหน้าต่าง Chrome ที่มีเครื่องมือสำหรับนักพัฒนาเว็บเปิดอยู่ในหน้า &quot;testlibusb&quot; ที่แสดงในเครื่อง คอนโซลเครื่องมือสำหรับนักพัฒนาเว็บกำลังประเมิน &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 ในระดับต่ำได้

บทสรุป

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

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

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