Chuyển các ứng dụng USB sang web. Phần 1: libusb

Tìm hiểu cách chuyển mã tương tác với thiết bị bên ngoài sang web bằng WebAssembly và API Fugu.

Trong bài đăng trước, tôi đã trình bày cách chuyển ứng dụng bằng API hệ thống tệp sang web bằng API Truy cập hệ thống tệp, WebAssembly và Asyncify. Bây giờ, tôi muốn tiếp tục chủ đề tương tự về việc tích hợp API Fugu với WebAssembly và chuyển ứng dụng sang web mà không làm mất các tính năng quan trọng.

Tôi sẽ hướng dẫn cách chuyển các ứng dụng giao tiếp với thiết bị USB sang web bằng cách chuyển libusb (một thư viện USB phổ biến được viết bằng C) sang WebAssembly (thông qua Emscripten), Asyncify và WebUSB.

Trước tiên: Bản minh hoạ

Điều quan trọng nhất cần làm khi chuyển thư viện là chọn bản minh hoạ phù hợp – một bản minh hoạ có thể giới thiệu các chức năng của thư viện được chuyển, cho phép bạn kiểm thử thư viện theo nhiều cách và đồng thời có hình ảnh hấp dẫn.

Ý tưởng tôi chọn là điều khiển từ xa DSLR. Cụ thể, một dự án nguồn mở gPhoto2 đã hoạt động trong lĩnh vực này đủ lâu để thực hiện kỹ thuật đảo ngược và triển khai dịch vụ hỗ trợ cho nhiều loại máy ảnh kỹ thuật số. Nó hỗ trợ một số giao thức, nhưng giao thức tôi quan tâm nhất là hỗ trợ USB, được thực hiện thông qua libusb.

Tôi sẽ mô tả các bước xây dựng bản minh hoạ này thành hai phần. Trong bài đăng này trên blog, tôi sẽ mô tả cách tôi chuyển libusb và những thủ thuật có thể cần thiết để chuyển các thư viện phổ biến khác sang API Fugu. Trong bài đăng thứ hai, tôi sẽ trình bày chi tiết về việc chuyển và tích hợp gPhoto2.

Cuối cùng, tôi đã có một ứng dụng web hoạt động được, có thể xem trước nguồn cấp dữ liệu trực tiếp từ máy ảnh DSLR và có thể kiểm soát các chế độ cài đặt của máy ảnh qua USB. Bạn có thể xem bản minh hoạ trực tiếp hoặc bản minh hoạ được ghi trước trước khi đọc thông tin chi tiết về kỹ thuật:

Bản minh hoạ chạy trên máy tính xách tay kết nối với máy ảnh Sony.

Lưu ý về các điểm đặc biệt của camera

Có thể bạn đã nhận thấy rằng việc thay đổi chế độ cài đặt trong video mất một chút thời gian. Giống như hầu hết các vấn đề khác mà bạn có thể gặp phải, vấn đề này không phải do hiệu suất của WebAssembly hoặc WebUSB, mà là do cách gPhoto2 tương tác với máy ảnh cụ thể được chọn cho bản minh hoạ.

Sony a6600 không hiển thị API để đặt trực tiếp các giá trị như ISO, khẩu độ hoặc tốc độ màn trập, mà chỉ cung cấp các lệnh để tăng hoặc giảm các giá trị đó theo số bước đã chỉ định. Để vấn đề trở nên phức tạp hơn, nó cũng không trả về danh sách các giá trị thực sự được hỗ trợ – danh sách được trả về có vẻ như được cố định giá trị trong mã trên nhiều mẫu máy ảnh Sony.

Khi đặt một trong các giá trị đó, gPhoto2 không có lựa chọn nào khác ngoài việc:

  1. Thực hiện một (hoặc một vài) bước theo hướng giá trị đã chọn.
  2. Chờ một chút để camera cập nhật chế độ cài đặt.
  3. Đọc lại giá trị mà camera thực sự đạt được.
  4. Kiểm tra để đảm bảo bước cuối cùng không vượt quá giá trị mong muốn cũng như không bao quanh phần cuối hoặc phần đầu của danh sách.
  5. Lặp lại.

Quá trình này có thể mất một chút thời gian, nhưng nếu giá trị thực sự được camera hỗ trợ, thì giá trị đó sẽ được áp dụng. Nếu không, giá trị được hỗ trợ gần nhất sẽ được áp dụng.

Các camera khác có thể có các bộ chế độ cài đặt, API cơ bản và đặc điểm riêng khác nhau. Xin lưu ý rằng gPhoto2 là một dự án nguồn mở và việc kiểm thử tự động hoặc thủ công tất cả các mẫu máy ảnh hiện có là không khả thi, vì vậy, chúng tôi luôn hoan nghênh các báo cáo vấn đề chi tiết và PR (nhưng trước tiên, hãy nhớ tái hiện các vấn đề bằng ứng dụng gPhoto2 chính thức).

Lưu ý quan trọng về khả năng tương thích trên nhiều nền tảng

Rất tiếc, trên Windows, mọi thiết bị "nổi tiếng", bao gồm cả máy ảnh DSLR, đều được chỉ định một trình điều khiển hệ thống không tương thích với WebUSB. Nếu muốn dùng thử bản minh hoạ trên Windows, bạn sẽ phải sử dụng một công cụ như Zadig để ghi đè trình điều khiển cho máy ảnh DSLR đã kết nối với WinUSB hoặc libusb. Cách này hiệu quả với tôi và nhiều người dùng khác, nhưng bạn nên tự chịu rủi ro khi sử dụng.

Trên Linux, bạn có thể cần phải thiết lập quyền tuỳ chỉnh để cho phép truy cập vào máy ảnh DSLR của bạn thông qua WebUSB, mặc dù điều này phụ thuộc vào bản phân phối của bạn.

Trên macOS và Android, bản minh hoạ sẽ hoạt động ngay lập tức. Nếu bạn đang thử trên điện thoại Android, hãy nhớ chuyển sang chế độ ngang vì tôi không nỗ lực nhiều để làm cho chế độ này thích ứng (rất hoan nghênh các PR!):

Điện thoại Android kết nối với máy ảnh Canon qua cáp USB-C.
Cùng một bản minh hoạ chạy trên điện thoại Android. Ảnh của Surma.

Để xem hướng dẫn chi tiết hơn về cách sử dụng WebUSB trên nhiều nền tảng, hãy xem phần "Những điều cần cân nhắc theo nền tảng cụ thể" trong bài viết "Xây dựng thiết bị cho WebUSB".

Thêm phần phụ trợ mới vào libusb

Giờ hãy chuyển sang phần chi tiết kỹ thuật. Mặc dù có thể cung cấp một API shim tương tự như libusb (điều này đã được những người khác thực hiện trước đây) và liên kết các ứng dụng khác với API đó, nhưng phương pháp này dễ xảy ra lỗi và khiến việc mở rộng hoặc bảo trì thêm trở nên khó khăn hơn. Tôi muốn làm mọi thứ đúng cách, theo cách có thể đóng góp ngược dòng và hợp nhất vào libusb trong tương lai.

May mắn là libusb README cho biết:

"libusb được trừu tượng hoá nội bộ theo cách có thể chuyển sang các hệ điều hành khác. Vui lòng xem tệp PORTING để biết thêm thông tin."

libusb được cấu trúc theo cách mà API công khai tách biệt với "phần phụ trợ". Các phần phụ trợ đó chịu trách nhiệm liệt kê, mở, đóng và thực sự giao tiếp với các thiết bị thông qua API cấp thấp của hệ điều hành. Đây là cách libusb loại bỏ sự khác biệt giữa Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku và Solaris, đồng thời hoạt động trên tất cả các nền tảng này.

Tôi phải thêm một phần phụ trợ khác cho "hệ điều hành" Emscripten+WebUSB. Quá trình triển khai cho các phần phụ trợ đó nằm trong thư mục 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

Mỗi phần phụ trợ đều bao gồm tiêu đề libusbi.h với các loại và trình trợ giúp phổ biến, đồng thời cần hiển thị biến usbi_backend thuộc loại usbi_os_backend. Ví dụ: đây là giao diện của phần phụ trợ 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),
};

Khi xem xét các thuộc tính, chúng ta có thể thấy rằng cấu trúc này bao gồm tên phần phụ trợ, một tập hợp các chức năng, trình xử lý cho nhiều hoạt động USB cấp thấp dưới dạng con trỏ hàm và cuối cùng là kích thước cần phân bổ để lưu trữ dữ liệu riêng tư ở cấp thiết bị/bối cảnh/chuyển giao.

Các trường dữ liệu riêng tư ít nhất cũng hữu ích cho việc lưu trữ các tay điều khiển hệ điều hành cho tất cả những thứ đó, vì nếu không có tay điều khiển, chúng ta sẽ không biết mục nào áp dụng cho thao tác cụ thể nào. Trong quá trình triển khai web, các trình xử lý hệ điều hành sẽ là các đối tượng JavaScript WebUSB cơ bản. Cách tự nhiên để biểu thị và lưu trữ các đối tượng này trong Emscripten là thông qua lớp emscripten::val, được cung cấp dưới dạng một phần của Embind (hệ thống liên kết của Emscripten).

Hầu hết các phần phụ trợ trong thư mục đều được triển khai bằng C, nhưng một số ít được triển khai bằng C++. Embind chỉ hoạt động với C++, vì vậy, tôi đã chọn và thêm libusb/libusb/os/emscripten_webusb.cpp với cấu trúc bắt buộc và sizeof(val) cho các trường dữ liệu riêng tư:

#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),
};

Lưu trữ đối tượng WebUSB dưới dạng tay điều khiển thiết bị

libusb cung cấp các con trỏ sẵn sàng sử dụng cho vùng được phân bổ cho dữ liệu riêng tư. Để làm việc với các con trỏ đó dưới dạng thực thể val, tôi đã thêm các trình trợ giúp nhỏ để tạo các con trỏ đó tại chỗ, truy xuất các con trỏ đó dưới dạng tệp đối chiếu và di chuyển các giá trị ra ngoài:

// 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 web không đồng bộ trong bối cảnh C đồng bộ

Giờ đây, chúng ta cần một cách để xử lý các API WebUSB không đồng bộ, trong đó libusb yêu cầu các thao tác đồng bộ. Để làm được điều này, tôi có thể sử dụng Asyncify hoặc cụ thể hơn là tích hợp Embind thông qua val::await().

Tôi cũng muốn xử lý chính xác các lỗi WebUSB và chuyển đổi chúng thành mã lỗi libusb, nhưng Embind hiện không có cách nào để xử lý các ngoại lệ JavaScript hoặc Promise từ chối từ phía C++. Bạn có thể giải quyết vấn đề này bằng cách phát hiện lệnh từ chối ở phía JavaScript và chuyển đổi kết quả thành đối tượng { error, value } hiện có thể phân tích cú pháp một cách an toàn từ phía C++. Tôi đã thực hiện việc này bằng cách kết hợp macro EM_JS và 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()};
  }
};

Giờ đây, tôi có thể sử dụng promise_result::await() trên mọi Promise được trả về từ các thao tác WebUSB và kiểm tra riêng các trường error và value của nó.

Ví dụ: việc truy xuất val đại diện cho USBDevice từ libusb_device_handle, gọi phương thức open(), chờ kết quả và trả về mã lỗi dưới dạng mã trạng thái libusb sẽ có dạng như sau:

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;
}

Liệt kê thiết bị

Tất nhiên, trước khi tôi có thể mở bất kỳ thiết bị nào, libusb cần truy xuất danh sách các thiết bị hiện có. Phần phụ trợ phải triển khai thao tác này thông qua trình xử lý get_device_list.

Khó khăn là, không giống như trên các nền tảng khác, không có cách nào để liệt kê tất cả các thiết bị USB được kết nối trên web vì lý do bảo mật. Thay vào đó, quy trình này được chia thành hai phần. Trước tiên, ứng dụng web yêu cầu các thiết bị có thuộc tính cụ thể thông qua navigator.usb.requestDevice() và người dùng chọn thiết bị mà họ muốn hiển thị hoặc từ chối lời nhắc cấp quyền theo cách thủ công. Sau đó, ứng dụng sẽ liệt kê các thiết bị thông minh đã được phê duyệt và kết nối thông qua navigator.usb.getDevices().

Lúc đầu, tôi đã cố gắng sử dụng trực tiếp requestDevice() trong quá trình triển khai trình xử lý get_device_list. Tuy nhiên, việc hiển thị lời nhắc cấp quyền kèm theo danh sách thiết bị thông minh được coi là một thao tác nhạy cảm và phải được kích hoạt bằng hoạt động tương tác của người dùng (như nhấp vào nút trên trang), nếu không, thao tác này sẽ luôn trả về một lời hứa bị từ chối. Các ứng dụng libusb thường muốn liệt kê các thiết bị thông minh khi khởi động ứng dụng, vì vậy, bạn không thể sử dụng requestDevice().

Thay vào đó, tôi phải để nhà phát triển cuối gọi navigator.usb.requestDevice() và chỉ hiển thị các thiết bị đã được phê duyệt từ 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;
}

Hầu hết mã phụ trợ đều sử dụng val và promise_result theo cách tương tự như đã trình bày ở trên. Có một vài thủ thuật thú vị khác trong mã xử lý việc chuyển dữ liệu, nhưng những chi tiết triển khai đó không quan trọng đối với mục đích của bài viết này. Hãy nhớ kiểm tra mã và nhận xét trên GitHub nếu bạn quan tâm.

Chuyển vòng lặp sự kiện sang web

Một phần nữa của cổng libusb mà tôi muốn thảo luận là xử lý sự kiện. Như đã mô tả trong bài viết trước, hầu hết các API trong ngôn ngữ hệ thống như C đều đồng bộ và việc xử lý sự kiện cũng không ngoại lệ. Vòng lặp này thường được triển khai thông qua một vòng lặp vô hạn "thăm dò ý kiến" (cố gắng đọc dữ liệu hoặc chặn quá trình thực thi cho đến khi có một số dữ liệu) từ một tập hợp các nguồn I/O bên ngoài và khi có ít nhất một trong số đó phản hồi, hãy chuyển nguồn đó dưới dạng sự kiện đến trình xử lý tương ứng. Sau khi trình xử lý hoàn tất, quyền kiểm soát sẽ quay lại vòng lặp và tạm dừng để thăm dò ý kiến khác.

Có một vài vấn đề với phương pháp này trên web.

Thứ nhất, WebUSB không và không thể hiển thị các tay điều khiển thô của thiết bị cơ bản, vì vậy, bạn không thể thăm dò trực tiếp các tay điều khiển đó. Thứ hai, libusb sử dụng API eventfd và pipe cho các sự kiện khác cũng như để xử lý hoạt động chuyển trên hệ điều hành mà không có tay điều khiển thiết bị thô, nhưng eventfd hiện không được hỗ trợ trong Emscripten và pipe, mặc dù được hỗ trợ, hiện không tuân thủ thông số kỹ thuật và không thể chờ sự kiện.

Cuối cùng, vấn đề lớn nhất là web có vòng lặp sự kiện riêng. Vòng lặp sự kiện chung này được dùng cho mọi hoạt động I/O bên ngoài (bao gồm cả fetch(), bộ tính giờ hoặc trong trường hợp này là WebUSB) và gọi trình xử lý sự kiện hoặc trình xử lý Promise bất cứ khi nào các hoạt động tương ứng hoàn tất. Việc thực thi một vòng lặp sự kiện vô hạn, lồng nhau khác sẽ chặn vòng lặp sự kiện của trình duyệt tiến triển, nghĩa là không chỉ giao diện người dùng sẽ không phản hồi mà mã cũng sẽ không bao giờ nhận được thông báo cho chính các sự kiện I/O mà mã đang chờ đợi. Điều này thường dẫn đến tình trạng bế tắc và đó cũng là điều đã xảy ra khi tôi cố gắng sử dụng libusb trong bản minh hoạ. Trang bị treo.

Tương tự như các hoạt động chặn I/O khác, để chuyển các vòng lặp sự kiện như vậy sang web, nhà phát triển cần tìm cách chạy các vòng lặp đó mà không chặn luồng chính. Một cách là tái cấu trúc ứng dụng để xử lý các sự kiện I/O trong một luồng riêng biệt và truyền kết quả trở lại luồng chính. Cách còn lại là sử dụng Asyncify để tạm dừng vòng lặp và chờ các sự kiện theo cách không chặn.

Tôi không muốn thực hiện những thay đổi quan trọng đối với libusb hoặc gPhoto2 và tôi đã sử dụng Asyncify để tích hợp Promise, vì vậy, đó là đường dẫn mà tôi đã chọn. Để mô phỏng một biến thể chặn của poll(), đối với bằng chứng về khái niệm ban đầu, tôi đã sử dụng một vòng lặp như minh hoạ dưới đây:

#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

Chức năng của công cụ này là:

  1. Gọi poll() để kiểm tra xem có sự kiện nào được phần phụ trợ báo cáo hay chưa. Nếu có, vòng lặp sẽ dừng lại. Nếu không, quá trình triển khai poll() của Emscripten sẽ trả về ngay lập tức bằng 0.
  2. Cuộc gọi emscripten_sleep(0). Hàm này sử dụng Asyncify và setTimeout() trong nền và được dùng ở đây để trả lại quyền kiểm soát cho vòng lặp sự kiện chính của trình duyệt. Điều này cho phép trình duyệt xử lý mọi hoạt động tương tác của người dùng và sự kiện I/O, bao gồm cả WebUSB.
  3. Kiểm tra xem thời gian chờ đã chỉ định đã hết hạn chưa và nếu chưa, hãy tiếp tục vòng lặp.

Như nhận xét đã đề cập, phương pháp này không tối ưu vì nó liên tục lưu-khôi phục toàn bộ ngăn xếp lệnh gọi bằng Asyncify ngay cả khi chưa có sự kiện USB nào cần xử lý (hầu hết thời gian) và vì bản thân setTimeout() có thời lượng tối thiểu là 4 mili giây trong các trình duyệt hiện đại. Tuy nhiên, giải pháp này vẫn hoạt động đủ tốt để tạo ra sự kiện phát trực tiếp 13-14 khung hình/giây từ máy ảnh DSLR trong bằng chứng về tính khả thi.

Sau đó, tôi quyết định cải thiện bằng cách tận dụng hệ thống sự kiện của trình duyệt. Có một số cách để cải thiện thêm cách triển khai này, nhưng hiện tại, tôi đã chọn phát trực tiếp các sự kiện tuỳ chỉnh trên đối tượng chung mà không liên kết các sự kiện đó với một cấu trúc dữ liệu libusb cụ thể. Tôi đã thực hiện việc này thông qua cơ chế chờ và thông báo sau đây dựa trên macro 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);
  }
});

Hàm em_libusb_notify() được dùng bất cứ khi nào libusb cố gắng báo cáo một sự kiện, chẳng hạn như hoàn tất quá trình chuyển dữ liệu:

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
}

Trong khi đó, phần em_libusb_wait() được dùng để "đánh thức" khỏi chế độ ngủ Asyncify khi nhận được sự kiện em-libusb hoặc khi hết thời gian chờ:

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;
}

Nhờ giảm đáng kể số lần ngủ và thức, cơ chế này đã khắc phục các vấn đề về hiệu suất của phương thức triển khai dựa trên emscripten_sleep() trước đó, đồng thời tăng thông lượng minh hoạ DSLR từ 13-14 khung hình/giây lên mức ổn định hơn 30 khung hình/giây, đủ để có một nguồn cấp dữ liệu trực tiếp mượt mà.

Hệ thống xây dựng và kiểm thử đầu tiên

Sau khi hoàn tất phần phụ trợ, tôi phải thêm phần này vào Makefile.am và configure.ac. Điểm thú vị duy nhất ở đây là việc sửa đổi cờ dành riêng cho 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']"
  ;;

Thứ nhất, các tệp thực thi trên nền tảng Unix thường không có đuôi tệp. Tuy nhiên, Emscripten tạo ra kết quả khác nhau tuỳ thuộc vào đuôi tệp mà bạn yêu cầu. Tôi đang sử dụng AC_SUBST(EXEEXT, …) để thay đổi đuôi tệp thực thi thành .html để mọi tệp thực thi trong một gói (bài kiểm thử và ví dụ) đều trở thành tệp HTML có shell mặc định của Emscripten, đảm nhận việc tải và tạo thực thể cho JavaScript và WebAssembly.

Thứ hai, vì đang sử dụng Embind và Asyncify, nên tôi cần bật các tính năng đó (--bind -s ASYNCIFY) cũng như cho phép tăng trưởng bộ nhớ động (-s ALLOW_MEMORY_GROWTH) thông qua các tham số trình liên kết. Rất tiếc, thư viện không có cách nào để báo cáo các cờ đó cho trình liên kết, vì vậy, mọi ứng dụng sử dụng cổng libusb này cũng sẽ phải thêm các cờ trình liên kết tương tự vào cấu hình bản dựng của chúng.

Cuối cùng, như đã đề cập trước đó, WebUSB yêu cầu việc liệt kê thiết bị phải được thực hiện thông qua cử chỉ của người dùng. Các ví dụ và kiểm thử libusb giả định rằng chúng có thể liệt kê các thiết bị khi khởi động và không thành công nếu không có thay đổi. Thay vào đó, tôi phải tắt tính năng thực thi tự động (-s INVOKE_RUN=0) và hiển thị phương thức callMain() thủ công (-s EXPORTED_RUNTIME_METHODS=...).

Sau khi hoàn tất tất cả những việc này, tôi có thể phân phát các tệp đã tạo bằng một máy chủ web tĩnh, khởi động WebUSB và chạy các tệp thực thi HTML đó theo cách thủ công với sự trợ giúp của DevTools.

Ảnh chụp màn hình cho thấy một cửa sổ Chrome có Công cụ cho nhà phát triển đang mở trên trang &quot;testlibusb&quot; được phân phát cục bộ. Bảng điều khiển Công cụ cho nhà phát triển đang đánh giá `navigator.usb.requestDevice({ filters: [] })`, lệnh này đã kích hoạt lời nhắc cấp quyền và hiện đang yêu cầu người dùng chọn một thiết bị USB cần được chia sẻ với trang. Bạn hiện đang chọn ILCE-6600 (một máy ảnh của Sony).

Ảnh chụp màn hình của bước tiếp theo, trong đó Công cụ cho nhà phát triển vẫn đang mở. Sau khi thiết bị được chọn, Console đã đánh giá một biểu thức mới `Module.callMain(['-v'])`, biểu thức này đã thực thi ứng dụng `testlibusb` ở chế độ chi tiết. Kết quả cho thấy nhiều thông tin chi tiết về camera USB đã kết nối trước đó: nhà sản xuất Sony, sản phẩm ILCE-6600, số sê-ri, cấu hình, v.v.

Trông có vẻ không nhiều, nhưng khi chuyển thư viện sang một nền tảng mới, việc đạt đến giai đoạn tạo ra kết quả hợp lệ lần đầu tiên là một điều khá thú vị!

Sử dụng cổng

Như đã đề cập ở trên, cổng này phụ thuộc vào một số tính năng của Emscripten hiện cần được bật ở giai đoạn liên kết của ứng dụng. Nếu muốn sử dụng cổng libusb này trong ứng dụng của riêng mình, bạn cần làm như sau:

  1. Tải libusb mới nhất xuống dưới dạng tệp lưu trữ trong bản dựng hoặc thêm tệp này dưới dạng mô-đun con git trong dự án của bạn.
  2. Chạy autoreconf -fiv trong thư mục libusb.
  3. Chạy emconfigure ./configure –host=wasm32 –prefix=/some/installation/path để khởi chạy dự án cho quá trình biên dịch chéo và đặt đường dẫn nơi bạn muốn đặt cấu phần phần mềm đã tạo.
  4. Chạy emmake make install.
  5. Trỏ ứng dụng hoặc thư viện cấp cao hơn của bạn để tìm libusb trong đường dẫn đã chọn trước đó.
  6. Thêm các cờ sau vào đối số liên kết của ứng dụng: --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH.

Thư viện này hiện có một số hạn chế:

  • Không hỗ trợ huỷ chuyển. Đây là một hạn chế của WebUSB, bắt nguồn từ việc thiếu tính năng huỷ chuyển giao trên nhiều nền tảng trong chính libusb.
  • Không hỗ trợ truyền dữ liệu đồng bộ. Việc thêm chế độ này không khó nếu làm theo cách triển khai các chế độ chuyển hiện có làm ví dụ, nhưng đây cũng là một chế độ hiếm gặp và tôi không có thiết bị nào để kiểm thử, nên hiện tại tôi chưa hỗ trợ chế độ này. Nếu bạn có các thiết bị như vậy và muốn đóng góp cho thư viện, chúng tôi rất hoan nghênh các yêu cầu kéo (PR)!
  • Các hạn chế trên nhiều nền tảng đã đề cập trước đó. Những hạn chế đó do hệ điều hành áp đặt, vì vậy, chúng tôi không thể làm gì nhiều ngoài việc yêu cầu người dùng ghi đè trình điều khiển hoặc quyền. Tuy nhiên, nếu đang chuyển thiết bị HID hoặc thiết bị nối tiếp, bạn có thể làm theo ví dụ về libusb và chuyển một số thư viện khác sang một API Fugu khác. Ví dụ: bạn có thể chuyển thư viện C hidapi sang WebHID và hoàn toàn tránh được những vấn đề liên quan đến quyền truy cập USB cấp thấp.

Kết luận

Trong bài đăng này, tôi đã trình bày cách thức, với sự trợ giúp của các API Emscripten, Asyncify và Fugu, ngay cả các thư viện cấp thấp như libusb cũng có thể được chuyển sang web bằng một vài thủ thuật tích hợp.

Việc chuyển các thư viện cấp thấp thiết yếu và được sử dụng rộng rãi như vậy đặc biệt hữu ích vì việc này cũng cho phép đưa các thư viện cấp cao hơn hoặc thậm chí là toàn bộ ứng dụng lên web. Điều này giúp mở ra những trải nghiệm mà trước đây chỉ dành cho người dùng của một hoặc hai nền tảng, cho tất cả các loại thiết bị và hệ điều hành, giúp những trải nghiệm đó chỉ cần một cú nhấp chuột là có thể truy cập được.

Trong bài đăng tiếp theo, tôi sẽ hướng dẫn các bước liên quan đến việc xây dựng bản minh hoạ gPhoto2 trên web. Bản minh hoạ này không chỉ truy xuất thông tin thiết bị mà còn sử dụng rộng rãi tính năng chuyển của libusb. Trong thời gian chờ đợi, tôi hy vọng bạn thấy ví dụ về libusb là nguồn cảm hứng và sẽ dùng thử bản minh hoạ, tự mình trải nghiệm thư viện hoặc thậm chí là tiếp tục và chuyển một thư viện được sử dụng rộng rãi khác sang một trong các API Fugu.