瞭解如何使用 WebAssembly 和 Fugu API,將與外部裝置互動的程式碼移植到網路。
在前一篇文章中,我曾瞭解如何使用 File System Access API、WebAssembly 和 Asyncify,將使用檔案系統 API 的應用程式移植到網路。現在我想繼續相同的主題,就是將 Fugu API 與 WebAssembly 整合,並將應用程式移植到網頁,同時保留重要功能。
我會示範如何將與 USB 裝置通訊的應用程式透過 libusb (以 C 語言編寫的熱門 USB 程式庫) 傳輸至 WebAssembly (透過 Emscripten)、Asyncify 和 WebUSB 等方式,將與 USB 裝置通訊的應用程式傳輸至網路。
首要之務:示範
移植程式庫時,最重要的是選擇正確的示範,也就是展示已移植程式庫的功能、以各種方式測試,並同時提升視覺吸引力。
我選擇的概念是數位單眼遙控器。具體來說,這個空間的開放原始碼專案 gPhoto2 已足以進行反向工程,並為各種數位相機提供支援。這款程式支援多種通訊協定,但我最感興趣的是 USB 支援,它會透過 libusb 執行。
我會分兩部分說明建構此示範內容的步驟。這篇網誌文章會說明我如何移植 Libusb,並說明將其他熱門程式庫移植到 Fugu API 時可能會用到的技巧。我們將在第二篇貼文中詳細說明攜碼轉移和整合 gPhoto2 的方式。
最後,我取得了一個運作中的網頁應用程式,可預覽 DSLR 的即時影像,並透過 USB 控制其設定。歡迎觀看現場或預錄的示範影片,然後再閱讀我們後續的相關技術細節:
相機專屬相關問題注意事項
您可能已註意到,變更設定需要一段時間才能完成。與多數可能出現的其他問題一樣,這並非 WebAssembly 或 WebUSB 的效能所導致,而是 gPhoto2 與選擇示範的特定相機互動的方式。
Sony a6600 不會公開用於設定 ISO、光圈或快門速度等值的 API,只會提供以指定步數增加或減少值的指令。為了方便起見,這個 API 不會傳回實際支援的值清單,因為傳回的清單在許多 Sony 相機型號中似乎是以硬式編碼的方式寫入。
設定其中一個值時,gPhoto2 無法選擇其他設定,只能執行以下操作:
- 根據所選值的方向建立一個步驟 (或數步)。
- 請稍候片刻,讓攝影機更新設定。
- 回想攝影機實際降落的價值。
- 確認最後一個步驟沒有跳過所需的值,也沒有自動繞過清單結尾或開頭。
- 樂趣無限循環
可能需要一些時間,但相機確實支援該值時,相機就會在該處取得該值,如果不是,則會停止使用最接近的支援值。
其他相機可能會有不同的設定組合、基礎 API 和特殊設定。請記住,gPhoto2 是一項開放原始碼專案,無法以自動或手動測試所有相機型號,因此歡迎詳細回報問題和公關部門 (但請務必先透過官方的 gPhoto2 用戶端重現問題)。
跨平台相容性的重要須知
可惜的是,在 Windows 上,任何「知名」裝置 (包括數位單眼相機) 都會獲派系統驅動程式,而該系統與 WebUSB 不相容。如要在 Windows 上試用示範模式,必須使用 Zadig 等工具將已連線 DSLR 的驅動程式覆寫為 WinUSB 或 libusb。這個方法對於我和許多其他使用者來說都沒有問題,但您必須自行承擔使用風險。
在 Linux 上,您可能需要設定自訂權限,才能透過 WebUSB 存取 DSLR,但這取決於您的發行版本。
在 macOS 和 Android 上,試用版應該可以立即運作。如要在 Android 手機上試用這項功能,請務必切換至橫向模式,因為我不太費力就能做出回應 (歡迎公關!):
如要深入瞭解關於 WebUSB 跨平台使用方法的詳盡指南,請參閱「為 WebUSB 打造裝置」一節的「平台專屬注意事項」一節。
將新後端新增至 libusb
接下來說明技術細節。雖然您也可以提供類似於 libusb 的填充 API (先前是由他人執行) 並連結至其他應用程式,但這個方法很容易出錯,且會導致任何進一步的延長或維護工作困難重重。我想做得正確的,但這麼做或許能使後遊出現,日後再併入 Libusb。
幸好,libusb README 說:
「libusb 從內部抽象化,希望能順利移植到其他作業系統。詳情請參閱 PORTING 檔案。」
libusb 的結構,其中公用 API 與「後端」不同。這些後端負責透過作業系統的低階 API 列出、開啟、關閉,以及實際與裝置通訊。libusb 以此取代 Linux、macOS、Windows、Android、OpenBSD/NetBSD、Haiku 和 Solaris 的差異,並在這些平台上運作。
我現在必須新增 Emscripten+WebUSB「作業系統」的另一個後端。這些後端的實作位於 libusb/os
資料夾中:
~/w/d/libusb $ ls libusb/os
darwin_usb.c haiku_usb_raw.h threads_posix.lo
darwin_usb.h linux_netlink.c threads_posix.o
events_posix.c linux_udev.c threads_windows.c
events_posix.h linux_usbfs.c threads_windows.h
events_posix.lo linux_usbfs.h windows_common.c
events_posix.o netbsd_usb.c windows_common.h
events_windows.c null_usb.c windows_usbdk.c
events_windows.h openbsd_usb.c windows_usbdk.h
haiku_pollfs.cpp sunos_usb.c windows_winusb.c
haiku_usb_backend.cpp sunos_usb.h windows_winusb.h
haiku_usb.h threads_posix.c
haiku_usb_raw.cpp threads_posix.h
每個後端都會包含包含常見類型和輔助程式的 libusbi.h
標頭,而且需要公開類型為 usbi_os_backend
的 usbi_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),
};
透過屬性,我們可看到 struct 包含後端名稱、一組功能、適用於各種低階 USB 作業的處理常式 (形式為函式指標),以及最後要分配的大小,用於儲存私人裝置/內容/傳輸層級資料。
私人資料欄位至少適合用來儲存所有作業的 OS 控制代碼,因為我們不知道任何指定作業適用於哪個項目。在網站實作中,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))) {}
};
在同步 C 環境中執行非同步網路 API
現在,我們需要一種方法來處理 libusb 預期進行同步作業的非同步 WebUSB API。為此,我可以使用 Asyncify,或者更具體地透過 val::await()
使用 Embind 整合功能。
我還想正確處理 WebUSB 錯誤,並將其轉換為 libusb 錯誤代碼,但 Embind 目前沒有任何方法處理 JavaScript 例外狀況,或 C++ 端的 Promise
遭拒。如要解決這個問題,請在 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()};
}
};
現在,我可以在從 WebUSB 作業傳回的任何 Promise
上使用 promise_result::await()
,並分別檢查其 error
和 value
欄位。
舉例來說,從 libusb_device_handle
擷取代表 USBDevice
的 val
、呼叫其 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 裝置。而是將流程分成兩個部分。首先,網頁應用程式透過 navigator.usb.requestDevice()
要求具有特定屬性的裝置,然後使用者手動選擇要公開或拒絕權限提示的裝置。之後,應用程式會透過 navigator.usb.getDevices()
列出已核准且已連結的裝置。
我一開始嘗試直接在 get_device_list
處理常式的實作中使用 requestDevice()
。不過,顯示附有已連結裝置清單的權限提示屬於敏感作業,而且必須由使用者互動 (例如按下網頁的按鈕) 觸發,否則一律會傳回已遭拒的 promise。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 連接埠還有事件處理。如前文所述,大部分系統語言 (例如 C) 的 API 都會執行同步,且事件處理作業也不例外。實作方法通常是透過無限迴圈來實作,該迴圈會從一組外部 I/O 來源中「輪詢」讀取資料或禁止執行,以及至少其中一個回應時,將該事件做為事件傳遞至對應的處理常式。處理常式完成後,控制項會返回迴圈,並暫停執行其他輪詢作業。
這個方法在網路上存在幾個問題。
首先,WebUSB 不會公開基礎裝置的原始控點,因此也無法直接輪詢這類裝置。其次,libusb 會使用 eventfd
和 pipe
API 來處理其他事件,以及在沒有原始裝置控制代碼的作業系統上處理轉移作業,但 Emscripten 和 pipe
目前並不支援 eventfd
,但支援目前的規格且無法等待事件。
最後,最大的問題是網路有自己的事件迴圈。這個全域事件迴圈會用於任何外部 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
用途:
- 呼叫
poll()
檢查後端是否已回報任何事件。如果有迴圈,迴圈會停止。否則, Emscripten 的poll()
實作會立即傳回0
。 - 呼叫
emscripten_sleep(0)
。這個函式實際上會使用 Asyncify 和setTimeout()
,此處將用於將控制項傳回主要瀏覽器事件迴圈。這可讓瀏覽器處理任何使用者互動和 I/O 事件,包括 WebUSB。 - 檢查指定的逾時時間是否已過期,如未過期,請繼續進行迴圈。
如註解提及,這個做法並不是理想做法,因為即使沒有可處理的 USB 事件 (時間大多數),且 setTimeout()
本身在新型瀏覽器中都最短為 4 毫秒,因此使用 Asyncify 會保留整個呼叫堆疊並還原為最佳方式。不過,在概念驗證方面,透過 DSLR 拍攝的 13 到 14 FPS 直播仍十分出色。
之後,我決定利用瀏覽器事件系統加以改善。有幾種方法可以進一步改善這項實作,但目前我選擇直接在全域物件上發出自訂事件,而不將自訂事件與特定 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);
}
});
每次 libusb 嘗試回報事件,例如資料移轉完成時,都會使用 em_libusb_notify()
函式:
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
事件或逾時到期,em_libusb_wait()
部分就會從 Asyncify 睡眠「喚醒」:
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()
實作的效率問題,並將數位單眼相機展示量從 13 至 14 FPS 提升到一致的 30+ FPS,足以讓即時動態饋給保持流暢。
建構系統和第一項測試
後端完成後,我必須將其新增至 Makefile.am
和 configure.ac
。值得注意的是,我們只修改了本地化的旗標標記:
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 平台上的執行檔通常沒有副檔名。不過,指令碼會依據您要求的副檔名產生不同的輸出內容。我會使用 AC_SUBST(EXEEXT, …)
將可執行擴充功能變更為 .html
,這樣套件中的所有執行檔 (測試和範例) 就會變成含有 Emscripten 預設殼層的 HTML,負責載入和執行個體化 JavaScript 和 WebAssembly。
其次,由於我使用 Embind 和 Asyncify,所以需要啟用這些功能 (--bind -s ASYNCIFY
),並透過連接器參數允許動態記憶體成長 (-s ALLOW_MEMORY_GROWTH
)。遺憾的是,程式庫無法向連結器回報這些標記,因此凡是使用這個 libusb 通訊埠的應用程式,也都必須在建構設定中加入相同的連結器旗標。
最後,如前文所述,WebUSB 要求必須透過使用者手勢進行裝置列舉。libusb 範例和測試會假設裝置可以在啟動時列舉裝置,並因錯誤而失敗而不進行變更。相反地,我必須停用自動執行功能 (-s INVOKE_RUN=0
),並公開手動 callMain()
方法 (-s EXPORTED_RUNTIME_METHODS=...
)。
完成上述所有動作後,我便能透過靜態網路伺服器提供產生的檔案、初始化 WebUSB,以及透過 DevTools 手動執行這些 HTML 執行檔。
雖然看起來不多,但當將程式庫移植到新平台時,讓程式庫第一次能產生有效的輸出內容,實在令人興奮!
使用通訊埠
如上文所述,通訊埠依附於應用程式連結階段目前需要啟用的幾個 Emscripten 功能。如果您要在自己的應用程式中使用這個 libusb 通訊埠,請執行下列步驟:
- 將最新的 libusb 下載為建構作業的封存檔,或是新增為專案的 Git 子模組。
- 在
libusb
資料夾中執行autoreconf -fiv
。 - 執行
emconfigure ./configure –host=wasm32 –prefix=/some/installation/path
將專案初始化,以便進行跨平台編譯,並設定要存放建構構件的路徑。 - 執行
emmake make install
。 - 將應用程式或高階程式庫指向在先前所選路徑之下的 Libusb。
- 請將下列標記新增至應用程式的連結引數:
--bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH
。
這個程式庫目前有一些限制:
- 不支援轉移取消功能。這是 WebUSB 的限制,但因 libusb 本身無法取消跨平台轉移。
- 無異狀轉移支援。按照現有傳輸模式的實作範例新增 Android 應該不是問題,但還是有點稀少的模式,而且我沒有任何裝置可以進行測試,因此這次我決定讓這個模式提供支援。如果你有這類裝置,並想對圖書館參與編輯,我們歡迎 PR!
- 先前提到跨平台限制。這些限制是由作業系統設定,因此除了要求使用者覆寫驅動程式或權限之外,我們無法執行大部分的操作。但如果您要移植 HID 或序列裝置,可以使用 libusb 範例,並將其他程式庫移植至其他 Fugu API。例如,您可以將 C 程式庫 hidapi 移植到 WebHID,並側解與低階 USB 存取相關聯的問題。
結論
在這篇文章中,我說明瞭如何在 Emscripten 的協助下,利用 Asyncify 和 Fugu API 將 libusb 等低階程式庫導入到網路上。
移植這類重要和廣受使用的低階程式庫對成效特別有幫助,因為,也能將高階程式庫或整個應用程式引入網路。過去只有一或兩個平台的使用者在享受一或兩個平台時,都能享有這些體驗,只要按一下連結就能享受這些體驗。
在下一篇文章中,我將逐步說明建置網路 gPhoto2 示範時的相關步驟,這些操作不僅可擷取裝置資訊,也廣泛運用 libusb 的傳輸功能。同時,希望您從中獲得了振奮人心的範例,希望您能試著進行示範、運用程式庫本身,或者甚至將其他廣為使用的程式庫移植到其中一個 Fugu API。