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 API WebAssembly và API Fugu.

Trong một bài đăng trước, tôi đã hướng dẫn cách chuyển ứng dụng bằng API hệ thống tệp sang web bằng File System Access API, WebAssembly và Asyncify. Bây giờ, tôi muốn tiếp tục chủ đề tích hợp APIFugu với WebAssembly và chuyển ứng dụng lên web mà không mất các tính năng quan trọng.

Tôi sẽ trình bày cách chuyển các ứng dụng kết nối 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: minh hoạ

Điều quan trọng nhất cần làm khi chuyển thư viện là chọn đúng bản minh hoạ. Bản minh hoạ thể hiện khả năng của thư viện đã chuyển, cho phép bạn thử nghiệm thư viện theo nhiều cách và hấp dẫn về mặt hình ảnh cùng lúc.

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

Tôi sẽ mô tả các bước xây dựng bản minh hoạ này trong hai phần. Trong bài đăng trên blog này, tôi sẽ mô tả cách tôi tự chuyển libusb và những thủ thuật 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ẽ đề cập chi tiết về cách chuyển và tích hợp gẢnh2.

Cuối cùng, tôi đã tải được một ứng dụng web đang hoạt động để 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 ứng dụng đó qua USB. Vui lòng xem bản minh hoạ trực tiếp hoặc bản minh hoạ được ghi âm sẵn trước khi đọc các chi tiết 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ề một số điểm khác biệt dành riêng cho máy ảnh

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

Sony a6600 không trực tiếp đưa ra API để thiết lập các giá trị như ISO, khẩu độ hoặc tốc độ màn trập mà chỉ đưa ra các lệnh tăng hoặc giảm các giá trị đó theo số bước được chỉ định. Để phức tạp hơn, phương thức này không trả về danh sách các giá trị thực sự được hỗ trợ. Danh sách 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òn lựa chọn nào khác ngoài việc:

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

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

Những camera khác có thể sẽ có những bộ chế độ cài đặt, API cơ bản và điểm đặc trưng riêng. Xin lưu ý rằng gẢnh2 là một dự án nguồn mở và việc kiểm tra tự động hoặc thủ công đối với tất cả các mô hình máy ảnh hiện có là không khả thi, vì vậy, các báo cáo và PR chi tiết luôn được chào đón (nhưng hãy đảm bảo tái tạo các sự cố với ứng dụng khách gẢnh2 chính thức trước tiên).

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 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 phải sử dụng một công cụ như Zadig để ghi đè trình điều khiển cho DSLR đã kết nối sang WinUSB hoặc libusb. Phương pháp này phù hợp với tôi và nhiều người dùng khác, nhưng bạn phải tự chịu rủi ro khi sử dụng.

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

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

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

Để xem hướng dẫn chi tiết hơn về việc 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 riêng cho nền tảng" 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

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

Thật may là tệp README của libusb cho biết:

“libusb được tóm tắt nội bộ theo cách có thể hy vọng có thể chuyển sang các hệ điều hành khác. Vui lòng xem tệp PORTING (CÔNG CỤ) để 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ỏ những điểm khác biệt giữa Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku và Solaris và hoạt động trên tất cả các nền tảng này.

Việc tôi phải làm là thêm một phần phụ trợ khác cho "hệ điều hành" Emscripten+WebUSB. Các cách 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ợ 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ị một biến usbi_backend thuộc loại usbi_os_backend. Ví dụ: đây là giao diện của phần phụ trợ của 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),
};

Xem qua các thuộc tính, chúng ta có thể thấy rằng cấu trúc bao gồm tên chương trình 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 khác nhau 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 cấp thiết bị/ngữ cảnh/chuyển riêng tư.

Các trường dữ liệu riêng tư ít nhất cũng hữu ích để lưu trữ tên người dùng của hệ điều hành cho tất cả những thứ đó, vì nếu không có tên người dùng, chúng ta không biết bất cứ thao tác cụ thể nào áp dụng cho mục nào. Trong quá trình triển khai web, 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 diễn và lưu trữ chúng trong Emscripten là thông qua lớp emscripten::val. Lớp này được cung cấp trong 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 được triển khai bằng C, nhưng một số phần phụ trợ được triển khai trong C++. Embind chỉ hoạt động với C++, vì vậy, tôi đã tự lựa chọn và thêm libusb/libusb/os/emscripten_webusb.cpp có cấu trúc bắt buộc cũng như 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ữ các đối tượng WebUSB làm tay điều khiển thiết bị

libusb cung cấp các con trỏ sẵn sàng sử dụng tới khu vực được phân bổ cho dữ liệu cá nhân. Để xử lý 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 chúng dưới dạng tham chiếu và di chuyển các giá trị ra:

// 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 ngữ cảnh C đồng bộ

Hiện cần một cách để xử lý các API WebUSB không đồng bộ, trong đó libusb mong đợi các hoạt động đồng bộ. Để làm việc này, tôi có thể sử dụng tính nă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ý ngoại lệ dành cho JavaScript hoặc từ chối Promise từ phía C++. Bạn có thể giải quyết vấn đề này bằng cách phát hiện sự từ chối ở phía JavaScript và chuyển đổi kết quả thành đối tượng { error, value } hiện có thể được 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()};
  }
};

Bây giờ, tôi có thể sử dụng promise_result::await() trên bất kỳ Promise nào được trả về từ các thao tác WebUSB, đồng thời kiểm tra riêng các trường errorvalue của nó.

Ví dụ: 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 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ở thiết bị bất kỳ, libusb cần truy xuất danh sách các thiết bị có sẵn. 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 sẽ đượ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ể 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ị đã được phê duyệt và kết nối qua navigator.usb.getDevices().

Lúc đầu, tôi đã cố sử dụng requestDevice() trực tiếp 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ị đã kết nối được xem là một hoạt động nhạy cảm và phải được kích hoạt khi người dùng tương tác (chẳng hạn như nhấp vào nút trên một trang), nếu không thì việc này 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ị đã kết nối khi khởi động ứng dụng, vì vậy không nên sử dụng requestDevice().

Thay vào đó, tôi phải để lại lệnh gọi navigator.usb.requestDevice() cho nhà phát triển cuối 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 valpromise_result theo cách tương tự như đã trình bày ở trên. Có một vài cách tấn công thú vị khác trong mã xử lý chuyển dữ liệu, nhưng những chi tiết triển khai đó không quan trọng cho 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 về 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 phải là ngoại lệ. Hành động 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 nhóm các nguồn I/O bên ngoài và khi ít nhất một trong các nguồn đó phản hồi, sẽ truyền sự kiện đó dưới dạng một sự kiện cho bộ xử lý tương ứng. Sau khi trình xử lý hoàn tất, chế độ điều khiển này sẽ quay lại vòng lặp và tạm dừng để thực hiện một cuộc 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ị tay cầm thô của các thiết bị cơ bản. Vì vậy, bạn không thể thăm dò trực tiếp các thiết bị đó. Thứ hai, libusb sử dụng API eventfdpipe cho các sự kiện khác cũng như để xử lý hoạt động chuyển dữ liệu trên các hệ điều hành không có tay xử lý thiết bị thô. Tuy nhiên, eventfd hiện không được hỗ trợ trong Emscripten và pipe, trong khi đượ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 Promise bất cứ khi nào các hoạt động tương ứng kết thúc. 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 không bao giờ tiến triển. Điều này có nghĩa là không chỉ giao diện người dùng sẽ không phản hồi mà còn cả mã sẽ không bao giờ nhận được thông báo cho cùng các sự kiện I/O mà mã đang chờ. Việc này thường dẫn đến tắc nghẽn 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.

Giống như với các I/O chặn khác, để chuyển vòng lặp sự kiện như vậy lên 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. Bạn có thể 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 và chuyển kết quả trở lại luồng chính. Hai là sử dụng tính nă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 các thay đổi quan trọng đối với libusb hoặc gphoto2 và tôi đã sử dụng tính năng Asyncify để tích hợp Promise, vì vậy đó là con đường tôi đã chọn. Để mô phỏng một biến thể chặn của poll(), để có bằng chứng ban đầu về khái niệm, 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 gói 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ó một số con số, vòng lặp sẽ dừng lại. Nếu không, hoạt động triển khai poll() của Emscripten sẽ ngay lập tức trả về cùng với 0.
  2. Gọi emscripten_sleep(0). Hàm này sử dụng Asyncify và setTimeout() nâng cao, đồng thời được dùng để mang lại quyền kiểm soát cho vòng lặp sự kiện chính của trình duyệt. Việc 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ờ được chỉ định đã hết hạn hay chưa, nếu chưa, hãy tiếp tục vòng lặp.

Như bình luận đã đề cập, phương pháp này không tối ưu vì phương pháp 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 để xử lý (trong phần lớn 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, nó hoạt động đủ tốt để tạo sự kiện phát trực tiếp 13-14 khung hình/giây từ máy ảnh DSLR theo mô hình bằng chứng về khái niệm.

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ó nhiều cách để cải thiện việc triển khai này, nhưng hiện tại tôi đã chọn phát các sự kiện tuỳ chỉnh trực tiếp trên đối tượng toàn cục mà không liên kết chúng với một cấu trúc dữ liệu libusb cụ thể. Tôi đã làm như vậ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 mỗi khi libusb cố gắng báo cáo một sự kiện, chẳng hạn như quá trình chuyển dữ liệu hoàn tất:

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" từ chế độ ngủ Asyncify khi nhận được sự kiện em-libusb hoặc thời gian chờ đã hết:

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

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

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

Sau khi phần phụ trợ hoàn tất, tôi phải thêm phần phụ trợ đó vào Makefile.amconfigure.ac. Điều thú vị duy nhất ở đây là 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 sẽ tạo ra kết quả khác nhau tuỳ thuộc vào tiện ích mà bạn yêu cầu. Tôi đang sử dụng AC_SUBST(EXEEXT, …) để thay đổi tiện ích thực thi thành .html để mọi tệp thực thi trong một gói (các bài kiểm thử và ví dụ) đều trở thành HTML có shell mặc định của Emscripten giúp tải và tạo thực thể cho JavaScript và WebAssembly.

Thứ hai, vì tôi đang 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 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ùng một cờ trình liên kết vào cấu hình bản dựng.

Cuối cùng, như đã đề cập trước đó, WebUSB yêu cầu thực hiện liệt kê thiết bị 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ê thiết bị khi khởi động và không thành công kèm theo lỗi nếu không có thay đổi. Thay vào đó, tôi phải tắt chế độ 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 được tạo bằng máy chủ web tĩnh, khởi chạy 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 Công cụ cho nhà phát triển.

Ả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á &quot;navigation.usb.requestDevice({ bộ lọc: [] })&quot;. Bảng điều khiển 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. Hiện đang chọn ILCE-6600 (máy ảnh 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 chọn thiết bị, Console đã đánh giá một biểu thức mới &quot;Module.callMain([&#39;-v&#39;])&quot;, biểu thức này đã thực thi ứng dụng &quot;testlibusb&quot; ở chế độ chi tiết. Đầu ra cho biết 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.

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

Sử dụng cổng

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

  1. Tải libusb mới nhất xuống dưới dạng tệp lưu trữ dưới dạng một phần của bản dựng hoặc thêm dưới dạng mô-đun con git trong dự á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à để thiết lập đường dẫn mà bạn muốn đặt các cấu phần phần mềm đã tạo.
  4. Chạy emmake make install.
  5. Trỏ ứng dụng của bạn hoặc thư viện cấp cao hơn để tìm kiếm libusb theo đườ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 hiện có một số hạn chế:

  • Không hỗ trợ huỷ yêu cầu chuyển. Đây là một hạn chế của WebUSB, do đó, bắt nguồn từ việc thiếu tính năng huỷ chuyển giữa nhiều nền tảng trong libusb.
  • Không hỗ trợ chuyển không đồng thời. Sẽ không có gì khó để thêm nó bằng cách làm theo ví dụ như cách triển khai các chế độ truyền hiện có, nhưng đây cũng là một chế độ hơi hiếm và tôi không có bất kỳ thiết bị nào để thử nghiệm nên hiện tại tôi không 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 bài PR!
  • Bài viết này có đề cập đến những hạn chế trên nhiều nền tảng. Những giới hạn đó do các hệ điều hành áp đặt, vì vậy chúng tôi không thể làm gì nhiều ở đây, ngoại trừ 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 đổi thiết bị HID hoặc thiết bị nối tiếp, bạn có thể làm theo ví dụ libusb và chuyển một số thư viện khác sang API Fugu khác. Ví dụ: bạn có thể chuyển hidapi thư viện C sang WebHID và khắc phục hoàn toàn 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 đã chỉ ra cách thức, với sự trợ giúp của API Emscripten, Asyncify và Fugu, thậm chí các thư viện cấp thấp như libusb cũng có thể được chuyển sang web với một vài thủ thuật tích hợp.

Việc chuyển đổi 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 là điều đặc biệt hữu ích, vì đổi lại, 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í toàn bộ ứng dụng lên web. Việc này mở ra các trải nghiệm trước đây chỉ dành cho người dùng của một hoặc hai nền tảng, cho mọi loại thiết bị và hệ điều hành, giúp người dùng có thể tận hưởng những trải nghiệm đó chỉ bằng một lần nhấp vào đường liên kết.

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ụ libusb này truyền cảm hứng và sẽ thử bản minh hoạ, chơi với chính thư viện hoặc thậm chí có thể 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.