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 các thiết bị bên ngoài sang web bằng API WebAssembly và Fugu.

Trong bài đăng trước, tôi đã hướng dẫn cách chuyển ứng dụng sử dụng API hệ thống tệp sang web bằng File System Access API (API Truy cập hệ thống tệp), WebAssembly và Asyncify. Bây giờ, tôi muốn tiếp tục cùng chủ đề tích hợp API Fugu với WebAssembly và chuyển các ứ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, hãy xem một 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ạ thể hiện được khả năng của thư viện đã 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 cho máy ảnh DSLR. Cụ thể, một dự án nguồn mở gPhoto2 đã hoạt động trong không gian này đủ lâu để kỹ sư đảo ngược và triển khai hỗ trợ cho nhiều loại máy ảnh kỹ thuật số. Thư viện này hỗ trợ một số giao thức, nhưng giao thức mà 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 trong hai phần. Trong bài đăng trên blog này, tôi sẽ mô tả cách tôi chuyển bản thâ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 đổi và tích hợp chính gPhoto2.

Cuối cùng, tôi đã có một ứng dụng web đang hoạt động, 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 ứng dụng đó qua USB. Bạn có thể xem bản minh hoạ trực tiếp hoặc bản minh hoạ được ghi sẵn trước khi đọc thông tin kỹ thuật chi tiết:

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

Lưu ý về các đặc điểm kỳ lạ của máy ảnh

Bạn có thể nhận thấy rằng việc thay đổi chế độ cài đặt trong video sẽ 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 gây ra, 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 để trực tiếp đặt 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, hàm này 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 mã hoá cứng 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 bước (hoặc một vài bước) theo hướng của giá trị đã chọn.
  2. Chờ một chút để máy ảnh cập nhật chế độ cài đặt.
  3. Đọc lại giá trị mà máy ảnh thực sự đã lấy.
  4. Kiểm tra để đảm bảo bước cuối cùng không bỏ qua giá trị mong muốn cũng như không gói ở cuối hoặc đầu danh sách.
  5. Lặp lại.

Quá trình này có thể 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ẽ được thiết lập. Nếu không, giá trị sẽ dừng ở giá trị được hỗ trợ gần nhất.

Các máy ảnh khác có thể có các chế độ cài đặt, API cơ bản và các đặc điểm khác. 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 đề và thông cáo báo chí chi tiết (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. Phương pháp này hoạt động tốt đối với tôi và nhiều người dùng khác, nhưng bạn nên tự chịu trách nhiệm khi sử dụng.

Trên Linux, có thể bạn sẽ cần đặt quyền tuỳ chỉnh để cho phép truy cập vào máy ảnh DSLR qua WebUSB, mặc dù điều này còn tuỳ 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 dành nhiều công sức để làm cho ứng dụng này thích ứng (chúng tôi hoan nghênh các yêu cầu phát hành công khai!):

Đ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.

Để biết 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" trong bài viết "Tạo thiết bị cho WebUSB".

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

Bây giờ, hãy chuyển sang thông tin 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 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ễ gặp 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 trở lại nguồn cấp dữ liệu và hợp nhất vào libusb trong tương lai.

May mắn thay, README libusb cho biết:

“libusb được trừu tượng hoá nội bộ theo cách có thể được 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 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 các API cấp thấp của hệ điều hành. Đây là cách libusb đã tóm tắt các đ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 cần làm là thêm một phần phụ trợ khác cho "hệ điều hành" Emscripten+WebUSB. Các phương thức 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ị biến usbi_backend thuộc loại usbi_os_backend. Ví dụ: Phần phụ trợ Windows có dạng như sau:

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 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 thao tác USB cấp thấp dưới dạng con trỏ hàm và cuối cùng là kích thước để phân bổ cho việc lưu trữ dữ liệu cấp thiết bị/cấp bối cảnh/cấp 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ữ 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 thao tác nào áp dụng cho mục nào. Trong quá trình triển khai web, các tay điều khiển 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. 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 này được triển khai bằng C, nhưng một số đượ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à với 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 dưới dạng tay điều khiển thiết bị

libusb cung cấp con trỏ sẵn sàng sử dụng đến vùng được phân bổ cho dữ liệu riêng tư. Để 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 các con trỏ đó dưới dạng tham 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 ngữ cảnh C đồng bộ

Bây giờ, bạn cần một cách để xử lý các API WebUSB không đồng bộ trong đó libusb dự kiến các thao tác đồng bộ. Để làm việc 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 các lỗi đó thành mã lỗi libusb, nhưng Embind hiện không có cách nào để xử lý các trường hợp ngoại lệ JavaScript hoặc các trường hợp 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 một trường hợp từ chối ở phía JavaScript và chuyển đổi kết quả thành một đối tượng { error, value } mà giờ đây 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à các 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 bất kỳ Promise nào được trả về từ các thao tác WebUSB và kiểm tra riêng các trường errorvalue của Promise đó.

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ở bất kỳ thiết bị nào, 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ả thiết bị USB đã kết nối trên web vì lý do bảo mật. Thay vào đó, luồng đượ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ó cá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ị đã được phê duyệt và kết nối thông qua navigator.usb.getDevices().

Ban đầ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ị đã kết nối đượ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 (chẳng hạn như lượt nhấp vào nút trên trang), nếu không, lời nhắ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ị đã kết nối 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 cùng 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 valpromise_result theo cách tương tự như đã trình bày ở trên. Có một số thủ thuật 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 đố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 khác 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 các 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ệ. Phương thức 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 ít nhất một trong số đó phản hồi, hãy truyền dữ liệu đó dưới dạng một 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.

Trước tiên, WebUSB không và không thể hiển thị các tay điều khiển thô của các thiết bị cơ bản, vì vậy, bạn không thể thăm dò ý kiến trực tiếp các tay điều khiển đó. Thứ hai, libusb sử dụng các API eventfdpipe cho các sự kiện khác cũng như để xử lý các lượt chuyển trên các hệ điều hành 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ờ các 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 toàn cục này được dùng cho mọi thao tác I/O bên ngoài (bao gồm fetch(), bộ hẹn giờ hoặc trong trường hợp này là WebUSB) và vòng lặp này sẽ gọi trình xử lý sự kiện hoặc Promise bất cứ khi nào các thao tác 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à giao diện người dùng không chỉ 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 những sự kiện I/O mà nó đang chờ. Điều này thường dẫn đến tình trạng tắc nghẽn và đó cũng là điều đã xảy ra khi tôi cố gắng sử dụng libusb trong một bản minh hoạ. Trang bị treo.

Giống như các hoạt động I/O chặn 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à chuyể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 đáng kể đối với libusb hoặc gPhoto2, đồng thời 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(), cho 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ạ bên dưới:

#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

Công cụ này có chức năng:

  1. Gọi poll() để kiểm tra xem phần phụ trợ đã báo cáo sự kiện nào chưa. Nếu có, vòng lặp sẽ dừng. Nếu không, việc triển khai poll() của Emscripten sẽ trả về ngay bằng 0.
  2. Gọi emscripten_sleep(0). Hàm này sử dụng Asyncify và setTimeout() trong phần nội dung 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 lượt 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 hay chưa. 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 để xử lý (trong hầu hết trường hợp) 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, trong quá trình kiểm thử, công cụ này vẫn hoạt động đủ tốt để tạo ra nội dung phát trực tiếp 13-14 khung hình/giây từ máy ảnh DSLR.

Sau đó, tôi quyết định cải thiện ứng dụng này 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 toàn cục 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" từ trạng thái 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;
}

Do 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 quả của phương thức triển khai dựa trên emscripten_sleep() trước đó và tăng băng thông bản minh hoạ DSLR từ 13-14 FPS lên 30 FPS trở lên một cách nhất quán, đủ để truyền trực tiếp mượt mà.

Xây dựng hệ thố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 phụ trợ đó vào Makefile.amconfigure.ac. Phần duy nhất thú vị ở đâ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']"
  ;;

Trước tiên, 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 bạn yêu cầu. Tôi đang sử dụng AC_SUBST(EXEEXT, …) để thay đổi phần mở rộng thực thi thành .html để mọi tệp thực thi trong một gói (các chương trình kiểm thử và ví dụ) trở thành một HTML có màn hình shell mặc định của Emscripten, giúp tải và tạo bản sao JavaScript và WebAssembly.

Thứ hai, vì tôi đ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 dung lượ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 thể 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 nào. 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ả các bước này, tôi có thể phân phát các tệp đã 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 nhờ sự trợ giúp của DevTools.

Ảnh chụp màn hình cho thấy một cửa sổ Chrome có DevTools đang mở trên trang `testlibusb` được phân phát cục bộ. Bảng điều khiển DevTools đ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 chia sẻ với trang. ILCE-6600 (máy ảnh Sony) hiện đang được chọn.

Ảnh chụp màn hình 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([&#39;-v&#39;])`, 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ề máy ảnh 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.

Có vẻ như không có gì đáng kể, 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 đầu ra hợp lệ lần đầu tiên là 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 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.
  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 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. Chỉ ứng dụng hoặc thư viện cấp cao hơ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ỷ quá trình chuyển. Đây là một hạn chế của WebUSB, do thiếu tính năng huỷ chuyển đổi trên nhiều nền tảng trong chính libusb.
  • Không hỗ trợ tính năng truyền đồng bộ. Bạn sẽ không gặp khó khăn gì khi thêm chế độ này bằng cách làm theo ví dụ về cách triển khai các chế độ chuyển hiện có, 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ử chế độ này, vì vậy, hiện tại tôi vẫn 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 đóng góp!
  • Các giới hạn trên nhiều nền tảng được đề cập trước đó. Những giới hạn đó do hệ điều hành áp đặt, vì vậy, chúng ta không thể làm gì nhiều ở đây, ngoại trừ 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ụ 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à bỏ qua 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 đã cho thấy cách nhờ 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 số 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 sẽ mang lại nhiều lợi í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í toàn bộ ứng dụng lên web. Nhờ đó, những trải nghiệm trước đây chỉ dành cho người dùng một hoặc hai nền tảng nay có thể được cung cấp cho tất cả các loại thiết bị và hệ điều hành, chỉ cần người dùng nhấp vào một đường liên kết.

Trong bài đăng tiếp theo, tôi sẽ hướng dẫn các bướ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 này rất thú vị và sẽ dùng thử bản minh hoạ, tự chơi với thư viện này hoặc thậm chí là chuyển một thư viện khác được sử dụng rộng rãi sang một trong các API Fugu.