เรียนรู้วิธีการย้ายโค้ดที่โต้ตอบกับอุปกรณ์ภายนอกไปยังเว็บด้วย WebAssembly และ Fugu API
ในโพสต์ก่อนหน้า เราได้แสดงวิธีพอร์ตแอปโดยใช้ API ของระบบไฟล์ไปยังเว็บด้วย File System Access API, WebAssembly และ Asyncify ตอนนี้ฉันต้องการหัวข้อเดิมในการผสานรวม Fugu API กับ WebAssembly และการพอร์ตแอปไปยังเว็บโดยที่ไม่สูญเสียฟีเจอร์สำคัญ
ฉันจะแสดงวิธีพอร์ตแอปที่สื่อสารกับอุปกรณ์ USB ไปยังเว็บได้ด้วยการย้าย libusb ซึ่งเป็นไลบรารี USB ยอดนิยมที่เขียนด้วยภาษา C ไปยัง WebAssembly (ผ่าน Emscripten), Asyncify และ WebUSB
อันดับแรก: การสาธิต
สิ่งสำคัญที่สุดที่ควรทำเมื่อย้ายไลบรารีคือการเลือกเดโมที่เหมาะสม ซึ่งเป็นสิ่งที่จะแสดงความสามารถของไลบรารีที่ย้ายแล้ว ทำให้คุณทดสอบไลบรารีได้หลากหลายวิธีและดึงดูดสายตาในเวลาเดียวกัน
ไอเดียที่ผมเลือกคือรีโมตคอนโทรล DSLR โดยเฉพาะอย่างยิ่งโครงการโอเพนซอร์ส gPhoto2 ในพื้นที่นี้นานพอที่จะทำวิศวกรรมย้อนกลับและรองรับกล้องดิจิทัลได้หลากหลายชนิด โปรโตคอลนี้รองรับโปรโตคอลหลายแบบ แต่แบบที่ผมสนใจมากที่สุดคือการรองรับ USB ซึ่งดำเนินการผ่าน libusb
เราจะอธิบายขั้นตอนในการสร้างการสาธิตนี้โดยแบ่งเป็น 2 ส่วน ในบล็อกโพสต์นี้ เราจะอธิบายวิธีที่ผมสร้าง Libusb และวิธีแนะนำที่อาจจำเป็นในการย้ายไลบรารียอดนิยมอื่นๆ มาเป็น Fugu API ในโพสต์ที่ 2 เราจะลงลึกรายละเอียดเกี่ยวกับการย้ายและผสานรวม gPhoto2
สุดท้าย ผมได้เว็บแอปพลิเคชันที่ใช้งานได้ซึ่งแสดงตัวอย่างฟีดสดจาก DSLR และควบคุมการตั้งค่าผ่าน USB ได้ คุณสามารถลองดูสดหรือการสาธิตที่บันทึกไว้ล่วงหน้าก่อนอ่านรายละเอียดทางเทคนิคต่อไปนี้
หมายเหตุเกี่ยวกับพฤติกรรมที่ไม่ปกติเฉพาะกล้อง
คุณอาจสังเกตเห็นว่าการเปลี่ยนการตั้งค่าจะใช้เวลาสักครู่ในวิดีโอ เช่นเดียวกับปัญหาอื่นๆ ส่วนใหญ่ที่คุณอาจพบ ข้อผิดพลาดนี้ไม่ได้เกิดจากประสิทธิภาพของ WebAssembly หรือ WebUSB แต่เกิดจากวิธีที่ gPhoto2 โต้ตอบกับกล้องที่เลือกสำหรับการสาธิต
Sony a6600 จะไม่แสดง API ให้กำหนดค่าต่างๆ เช่น ISO, รูรับแสง หรือความเร็วชัตเตอร์โดยตรง แต่จะมีคำสั่งให้เพิ่มหรือลดตามจำนวนขั้นตอนที่ระบุเท่านั้น และเพื่อทำให้กรณีซับซ้อนมากขึ้น ระบบจะไม่แสดงรายการค่าที่รองรับจริง เนื่องจากรายการที่ส่งคืนดูเหมือนว่าจะมีการฮาร์ดโค้ดในกล้อง Sony หลายรุ่น
เมื่อตั้งค่าหนึ่งในค่าเหล่านั้น gPhoto2 จะไม่มีทางเลือกอื่นนอกจากทำสิ่งต่อไปนี้
- สร้างขั้นตอนหนึ่ง (หรือ 2-3 ขั้นตอน) ตามทิศทางของค่าที่เลือก
- โปรดรอสักครู่ให้กล้องอัปเดตการตั้งค่า
- อ่านมูลค่าที่กล้องไปถึงจริงๆ
- ตรวจสอบว่าขั้นตอนสุดท้ายไม่ได้ข้ามค่าที่ต้องการหรือไม่ได้รวมไว้ที่ส่วนท้ายหรือส่วนต้นของรายการ
- ทำซ้ำ
ซึ่งอาจใช้เวลาสักระยะ แต่หากกล้องรองรับค่านี้จริงๆ ค่าจะอยู่ในนั้น หากไม่เป็นเช่นนั้น กล้องจะหยุดที่ค่าใกล้เคียงที่สุดที่รองรับ
กล้องอื่นๆ มักจะมีชุดการตั้งค่า, API ที่สำคัญ และพฤติกรรมที่ไม่ปกติแตกต่างกัน โปรดทราบว่า gPhoto2 เป็นโครงการโอเพนซอร์ส และไม่ว่าการทดสอบกล้องทั้งหมดโดยอัตโนมัติหรือด้วยตนเองจะเป็นไปไม่ได้เลย ดังนั้นคุณสามารถรายงานปัญหาและแจ้ง PR โดยละเอียดได้เสมอ (แต่โปรดทำซ้ำปัญหาด้วยไคลเอ็นต์ gPhoto2 อย่างเป็นทางการก่อน)
หมายเหตุสำคัญเกี่ยวกับความเข้ากันได้แบบข้ามแพลตฟอร์ม
อย่างไรก็ตาม สำหรับ Windows อุปกรณ์ "ที่รู้จักกันดี" ทั้งหมด ซึ่งรวมถึงกล้อง DSLR จะมีไดรเวอร์ของระบบ ซึ่งใช้งานกับ WebUSB ไม่ได้ หากต้องการลองใช้เดโมใน Windows คุณจะต้องใช้เครื่องมือ เช่น Zadig เพื่อลบล้างไดรเวอร์สำหรับ DSLR ที่เชื่อมต่อไปที่ WinUSB หรือ Libusb วิธีนี้ใช้ได้ดีสำหรับฉันและผู้ใช้คนอื่นๆ แต่คุณควรรับความเสี่ยงนี้เอง
ใน Linux คุณอาจต้องตั้งค่าสิทธิ์ที่กำหนดเองเพื่ออนุญาตการเข้าถึง DSLR ผ่าน WebUSB อย่างไรก็ตาม ขึ้นอยู่กับการเผยแพร่ของคุณ
การสาธิตสำหรับ macOS และ Android ควรใช้งานได้ตั้งแต่แรก หากคุณลองใช้บนโทรศัพท์ Android อย่าลืมเปลี่ยนเป็นโหมดแนวนอนเนื่องจากฉันไม่ได้ใช้ความพยายามมากนักในการตอบสนอง (ยินดีต้อนรับ PR)
สำหรับคำแนะนำเจาะลึกเกี่ยวกับการใช้ WebUSB แบบข้ามแพลตฟอร์ม โปรดดูส่วน"ข้อควรพิจารณาเฉพาะแพลตฟอร์ม" ของ "การสร้างอุปกรณ์สำหรับ WebUSB"
การเพิ่มแบ็กเอนด์ใหม่ไปยัง libusb
ตอนนี้มาดูรายละเอียดทางเทคนิคกัน แม้ว่าจะสามารถจัดเตรียม API ของ shim ที่คล้ายกับ libusb ได้ (ซึ่งผู้อื่นเคยดำเนินการมาก่อนแล้ว) และลิงก์แอปพลิเคชันอื่นๆ ด้วย วิธีนี้ก็มีโอกาสผิดพลาดได้ง่ายและทำให้การขยายหรือการบำรุงรักษาทำได้ยากขึ้น เราอยากจะให้ถูกต้องในแบบที่มีโอกาสจะมีส่วนสนับสนุนในต้นกระแสและรวมเข้าเป็น Libusb ในอนาคต
โชคดีที่ libusb README กล่าวว่า:
"libusb จัดทำขึ้นเป็นการภายในในลักษณะที่มีความเป็นไปได้ว่าจะสามารถนำไปใช้กับระบบปฏิบัติการอื่นๆ ได้ โปรดดูข้อมูลเพิ่มเติมในไฟล์การย้าย"
libusb มีโครงสร้างในลักษณะที่ API สาธารณะแยกจาก "แบ็กเอนด์" แบ็กเอนด์เหล่านี้มีหน้าที่ในการสร้างรายการ เปิด ปิด และสื่อสารกับอุปกรณ์จริงๆ ผ่าน API ระดับต่ำของระบบปฏิบัติการ วิธีนี้จะช่วยคัดแยกความแตกต่างระหว่าง Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku และ Solaris ออก รวมถึงทำงานได้ในทุกแพลตฟอร์ม
ฉันต้องเพิ่มแบ็กเอนด์อื่นสำหรับ "ระบบปฏิบัติการ" Emscripten+WebUSB การติดตั้งใช้งานสำหรับแบ็กเอนด์เหล่านี้จะอยู่ในโฟลเดอร์ libusb/os
ดังนี้
~/w/d/libusb $ ls libusb/os
darwin_usb.c haiku_usb_raw.h threads_posix.lo
darwin_usb.h linux_netlink.c threads_posix.o
events_posix.c linux_udev.c threads_windows.c
events_posix.h linux_usbfs.c threads_windows.h
events_posix.lo linux_usbfs.h windows_common.c
events_posix.o netbsd_usb.c windows_common.h
events_windows.c null_usb.c windows_usbdk.c
events_windows.h openbsd_usb.c windows_usbdk.h
haiku_pollfs.cpp sunos_usb.c windows_winusb.c
haiku_usb_backend.cpp sunos_usb.h windows_winusb.h
haiku_usb.h threads_posix.c
haiku_usb_raw.cpp threads_posix.h
แบ็กเอนด์แต่ละรายการจะมีส่วนหัว libusbi.h
ที่มีประเภทและตัวช่วยทั่วไป และจำเป็นต้องเปิดเผยตัวแปร usbi_backend
ประเภท usbi_os_backend
เช่น แบ็กเอนด์ของ Windows จะมีลักษณะดังนี้
const struct usbi_os_backend usbi_backend = {
"Windows",
USBI_CAP_HAS_HID_ACCESS,
windows_init,
windows_exit,
windows_set_option,
windows_get_device_list,
NULL, /* hotplug_poll */
NULL, /* wrap_sys_device */
windows_open,
windows_close,
windows_get_active_config_descriptor,
windows_get_config_descriptor,
windows_get_config_descriptor_by_value,
windows_get_configuration,
windows_set_configuration,
windows_claim_interface,
windows_release_interface,
windows_set_interface_altsetting,
windows_clear_halt,
windows_reset_device,
NULL, /* alloc_streams */
NULL, /* free_streams */
NULL, /* dev_mem_alloc */
NULL, /* dev_mem_free */
NULL, /* kernel_driver_active */
NULL, /* detach_kernel_driver */
NULL, /* attach_kernel_driver */
windows_destroy_device,
windows_submit_transfer,
windows_cancel_transfer,
NULL, /* clear_transfer_priv */
NULL, /* handle_events */
windows_handle_transfer_completion,
sizeof(struct windows_context_priv),
sizeof(union windows_device_priv),
sizeof(struct windows_device_handle_priv),
sizeof(struct windows_transfer_priv),
};
เมื่อพิจารณาคุณสมบัติ เราจะเห็นว่าโครงสร้างประกอบด้วยชื่อแบ็กเอนด์ ชุดความสามารถของฟังก์ชัน เครื่องจัดการสำหรับการดำเนินการ USB ระดับต่ำต่างๆ ในรูปแบบตัวชี้ฟังก์ชัน และสุดท้ายคือขนาดที่จะจัดสรรสำหรับการจัดเก็บข้อมูลระดับอุปกรณ์ส่วนตัว/บริบท-/การโอนข้อมูล
ช่องข้อมูลส่วนตัวมีประโยชน์อย่างน้อยในการจัดเก็บแฮนเดิล OS สำหรับสิ่งต่างๆ เหล่านั้น เนื่องจากเมื่อไม่มีแฮนเดิล เราจะไม่ทราบว่าการดำเนินการที่ระบุนั้นมีผลกับรายการใดบ้าง ในการใช้งานเว็บ แฮนเดิลระบบปฏิบัติการจะเป็นออบเจ็กต์ WebUSB JavaScript ที่เกี่ยวข้อง วิธีทั่วไปในการนำเสนอและจัดเก็บค่าเหล่านี้ใน Emscripten คือผ่านคลาส emscripten::val
ซึ่งเป็นส่วนหนึ่งของ Embind (ระบบการเชื่อมโยงของ Emscripten)
แบ็กเอนด์ส่วนใหญ่ในโฟลเดอร์มีการใช้งานใน C แต่มีเพียงไม่กี่รายการที่ใช้งานใน C++ Embind ใช้งานเฉพาะกับ C++ เท่านั้น ทางนี้ก็เลยตัดสินใจเองและฉันได้เพิ่ม libusb/libusb/os/emscripten_webusb.cpp
ด้วยโครงสร้างที่จำเป็น และ sizeof(val)
สำหรับช่องข้อมูลส่วนตัว
#include <emscripten.h>
#include <emscripten/val.h>
#include "libusbi.h"
using namespace emscripten;
// …function implementations
const usbi_os_backend usbi_backend = {
.name = "Emscripten + WebUSB backend",
.caps = LIBUSB_CAP_HAS_CAPABILITY,
// …handlers—function pointers to implementations above
.device_priv_size = sizeof(val),
.transfer_priv_size = sizeof(val),
};
การจัดเก็บออบเจ็กต์ WebUSB เป็นแฮนเดิลอุปกรณ์
Libusb มอบตัวชี้แบบพร้อมใช้ไปยังพื้นที่ที่จัดสรรไว้สำหรับข้อมูลส่วนตัว ในการใช้งานตัวชี้เหล่านั้นเป็นอินสแตนซ์ val
ฉันได้เพิ่มตัวช่วยขนาดเล็กที่สร้างเคอร์เซอร์ในตำแหน่งนี้ เรียกดูเป็นการอ้างอิง และย้ายค่าออก
// We store an Embind handle to WebUSB USBDevice in "priv" metadata of
// libusb device, this helper returns a pointer to it.
struct ValPtr {
public:
void init_to(val &&value) { new (ptr) val(std::move(value)); }
val &get() { return *ptr; }
val take() { return std::move(get()); }
protected:
ValPtr(val *ptr) : ptr(ptr) {}
private:
val *ptr;
};
struct WebUsbDevicePtr : ValPtr {
public:
WebUsbDevicePtr(libusb_device *dev)
: ValPtr(static_cast<val *>(usbi_get_device_priv(dev))) {}
};
val &get_web_usb_device(libusb_device *dev) {
return WebUsbDevicePtr(dev).get();
}
struct WebUsbTransferPtr : ValPtr {
public:
WebUsbTransferPtr(usbi_transfer *itransfer)
: ValPtr(static_cast<val *>(usbi_get_transfer_priv(itransfer))) {}
};
API ของเว็บที่ไม่พร้อมกันในบริบท C แบบซิงโครนัส
ตอนนี้ต้องการวิธีจัดการ WebUSB API แบบไม่พร้อมกัน โดยที่ Libusb คาดหวังการดำเนินการแบบซิงโครนัส สำหรับเรื่องนี้ ฉันจะใช้ Asyncify หรือกล่าวอย่างเจาะจงคือ ใช้การผสานรวม Embind ผ่าน val::await()
ก็ได้
ฉันต้องการจัดการข้อผิดพลาด WebUSB ให้ถูกต้องและแปลงเป็นรหัสข้อผิดพลาด libusb แต่ขณะนี้ Embind ยังไม่มีวิธีจัดการข้อยกเว้น JavaScript หรือการปฏิเสธ Promise
จากฝั่ง C++ ปัญหานี้สามารถแก้ไขได้โดยการดูการปฏิเสธทางฝั่ง JavaScript และแปลงผลลัพธ์เป็นออบเจ็กต์ { error, value }
ซึ่งตอนนี้สามารถแยกวิเคราะห์ได้อย่างปลอดภัยจากฝั่ง C++ ฉันดำเนินการโดยใช้มาโคร EM_JS
และ Emval.to{Handle, Value}
API ร่วมกัน
EM_JS(EM_VAL, em_promise_catch_impl, (EM_VAL handle), {
let promise = Emval.toValue(handle);
promise = promise.then(
value => ({error : 0, value}),
error => {
const ERROR_CODES = {
// LIBUSB_ERROR_IO
NetworkError : -1,
// LIBUSB_ERROR_INVALID_PARAM
DataError : -2,
TypeMismatchError : -2,
IndexSizeError : -2,
// LIBUSB_ERROR_ACCESS
SecurityError : -3,
…
};
console.error(error);
let errorCode = -99; // LIBUSB_ERROR_OTHER
if (error instanceof DOMException)
{
errorCode = ERROR_CODES[error.name] ?? errorCode;
}
else if (error instanceof RangeError || error instanceof TypeError)
{
errorCode = -2; // LIBUSB_ERROR_INVALID_PARAM
}
return {error: errorCode, value: undefined};
}
);
return Emval.toHandle(promise);
});
val em_promise_catch(val &&promise) {
EM_VAL handle = promise.as_handle();
handle = em_promise_catch_impl(handle);
return val::take_ownership(handle);
}
// C++ struct representation for {value, error} object from above
// (performs conversion in the constructor).
struct promise_result {
libusb_error error;
val value;
promise_result(val &&result)
: error(static_cast<libusb_error>(result["error"].as<int>())),
value(result["value"]) {}
// C++ counterpart of the promise helper above that takes a promise, catches
// its error, converts to a libusb status and returns the whole thing as
// `promise_result` struct for easier handling.
static promise_result await(val &&promise) {
promise = em_promise_catch(std::move(promise));
return {promise.await()};
}
};
ตอนนี้ฉันใช้ promise_result::await()
ใน Promise
ที่ส่งคืนจากการดำเนินการของ WebUSB ได้แล้ว และตรวจสอบช่อง error
และ value
แยกกัน
ตัวอย่างเช่น การเรียกข้อมูล val
ที่แทน USBDevice
จาก libusb_device_handle
โดยเรียกใช้เมธอด open()
ของกระบวนการ กำลังรอผลลัพธ์ และส่งคืนรหัสข้อผิดพลาดเป็นรหัสสถานะ Libusb ในลักษณะนี้
int em_open(libusb_device_handle *handle) {
auto web_usb_device = get_web_usb_device(handle->dev);
return promise_result::await(web_usb_device.call<val>("open")).error;
}
การแจงนับอุปกรณ์
แน่นอนว่าก่อนที่ฉันจะเปิดอุปกรณ์ได้ Libusb ต้องเรียกข้อมูลรายการอุปกรณ์ที่มี แบ็กเอนด์ต้องทำการดำเนินการนี้ผ่านเครื่องจัดการ get_device_list
ปัญหาคือ ไม่เหมือนกับแพลตฟอร์มอื่นๆ ตรงที่ไม่มีวิธีแจกแจงอุปกรณ์ USB ที่เชื่อมต่ออยู่ทั้งหมดบนเว็บด้วยเหตุผลด้านความปลอดภัย แต่จะแบ่งออกเป็น 2 ส่วนแทน ขั้นแรก เว็บแอปพลิเคชันจะขออุปกรณ์ซึ่งมีพร็อพเพอร์ตี้เฉพาะผ่าน navigator.usb.requestDevice()
และให้ผู้ใช้เลือกอุปกรณ์ที่ต้องการแสดงหรือปฏิเสธข้อความแจ้งสิทธิ์ด้วยตนเอง หลังจากนั้น แอปพลิเคชันจะแสดงอุปกรณ์ที่ได้รับอนุมัติและเชื่อมต่อแล้วผ่าน navigator.usb.getDevices()
ตอนแรกฉันลองใช้ requestDevice()
โดยตรงในการใช้งานเครื่องจัดการ get_device_list
อย่างไรก็ตาม การแสดงข้อความแจ้งสิทธิ์พร้อมรายการอุปกรณ์ที่เชื่อมต่อถือว่าเป็นการดำเนินการที่มีความละเอียดอ่อน และต้องทริกเกอร์โดยการโต้ตอบของผู้ใช้ (เช่น การคลิกปุ่มในหน้าเว็บ) มิเช่นนั้นระบบจะส่งคืนสัญญาที่ถูกปฏิเสธเสมอ แอปพลิเคชัน Libusb อาจต้องการแสดงรายการอุปกรณ์ที่เชื่อมต่อเมื่อเริ่มต้นแอปพลิเคชัน ดังนั้นการใช้ requestDevice()
จึงไม่ใช่ทางเลือก
เราต้องปล่อยให้นักพัฒนาซอฟต์แวร์ปลายทางเรียกใช้ navigator.usb.requestDevice()
แทน และแสดงเฉพาะอุปกรณ์ที่ได้รับอนุมัติแล้วจาก navigator.usb.getDevices()
ดังนี้
// Store the global `navigator.usb` once upon initialisation.
thread_local const val web_usb = val::global("navigator")["usb"];
int em_get_device_list(libusb_context *ctx, discovered_devs **devs) {
// C++ equivalent of `await navigator.usb.getDevices()`.
// Note: at this point we must already have some devices exposed -
// caller must have called `await navigator.usb.requestDevice(...)`
// in response to user interaction before going to LibUSB.
// Otherwise this list will be empty.
auto result = promise_result::await(web_usb.call<val>("getDevices"));
if (result.error) {
return result.error;
}
auto &web_usb_devices = result.value;
// Iterate over the exposed devices.
uint8_t devices_num = web_usb_devices["length"].as<uint8_t>();
for (uint8_t i = 0; i < devices_num; i++) {
auto web_usb_device = web_usb_devices[i];
// …
*devs = discovered_devs_append(*devs, dev);
}
return LIBUSB_SUCCESS;
}
โค้ดแบ็กเอนด์ส่วนใหญ่ใช้ val
และ promise_result
ในลักษณะเดียวกันกับที่แสดงข้างต้นแล้ว มีการแฮ็กที่น่าสนใจอื่นๆ อีกเล็กน้อยในโค้ดการจัดการการโอนข้อมูล แต่รายละเอียดการใช้งานเหล่านั้นจะไม่ค่อยสำคัญสำหรับวัตถุประสงค์ของบทความนี้ อย่าลืมตรวจสอบโค้ดและความคิดเห็นใน GitHub หากสนใจ
การพอร์ตเหตุการณ์วนซ้ำไปยังเว็บ
อีกเรื่องหนึ่งเกี่ยวกับพอร์ต Libusb ที่ผมอยากพูดถึงคือการจัดการเหตุการณ์ ตามที่อธิบายไว้ในบทความก่อนหน้า API ส่วนใหญ่ในภาษาของระบบอย่าง C จะทำงานแบบพร้อมกัน และการจัดการเหตุการณ์ก็เช่นกัน โดยปกติจะใช้การวนซ้ำที่ไม่มีที่สิ้นสุดซึ่ง "แบบสำรวจ" (จะพยายามอ่านข้อมูลหรือบล็อกการดำเนินการจนกว่าจะมีข้อมูลบางอย่าง) จากชุดแหล่งที่มา I/O ภายนอก และเมื่อมีการตอบสนองอย่างน้อย 1 รายการ จะส่งผ่านเป็นเหตุการณ์ไปยังเครื่องจัดการที่เกี่ยวข้อง เมื่อตัวแฮนเดิลเสร็จสิ้นแล้ว ตัวควบคุมจะกลับไปที่ลูป และหยุดชั่วคราวสำหรับแบบสำรวจอื่น
วิธีการนี้มีปัญหา 2-3 ข้อในเว็บ
ข้อแรก WebUSB จะไม่แสดงและไม่สามารถแสดงแฮนเดิลดิบของอุปกรณ์ที่สำคัญ ดังนั้นการสำรวจอุปกรณ์โดยตรงจึงไม่ใช่ทางเลือก ประการที่ 2 libusb ใช้ API ของ eventfd
และ pipe
สำหรับเหตุการณ์อื่นๆ เช่นเดียวกับการจัดการการโอนในระบบปฏิบัติการที่ไม่มีแฮนเดิลอุปกรณ์ซึ่งเป็นข้อมูลดิบ แต่ขณะนี้ eventfd
ยังไม่รองรับใน Emscripten และ pipe
แม้จะรองรับการใช้งาน แต่ตอนนี้ยังไม่เป็นไปตามข้อกำหนด และจะรอเหตุการณ์ไม่ได้
สุดท้าย ปัญหาที่ใหญ่ที่สุดคือเว็บมีการวนซ้ำเหตุการณ์ของตัวเอง ลูปเหตุการณ์ส่วนกลางนี้ใช้สําหรับการดำเนินการ I/O ภายนอก (รวมถึง fetch()
, ตัวจับเวลา หรือในกรณีนี้คือ WebUSB) และจะเรียกเหตุการณ์หรือเครื่องจัดการ Promise
ทุกครั้งที่การดำเนินการที่เกี่ยวข้องเสร็จสิ้น การเรียกใช้ลูปเหตุการณ์ซ้ำที่ซ้อนอยู่และไม่สิ้นสุดจะบล็อกเหตุการณ์วนซ้ำของเบราว์เซอร์ไม่ให้ดำเนินไปตลอด ซึ่งหมายความว่า UI จะไม่ตอบสนองเท่านั้น แต่โค้ดก็จะไม่ได้รับการแจ้งเตือนสำหรับเหตุการณ์ I/O ที่รออยู่อีกเช่นกัน ซึ่งมักจะทำให้เกิดการติดตาย และนั่นคือสิ่งที่เกิดขึ้นเมื่อผมพยายามใช้ Libusb ในเดโมด้วย หน้าเว็บค้าง
นักพัฒนาซอฟต์แวร์ต้องหาวิธีเรียกใช้ลูปเหล่านั้นโดยไม่บล็อกเทรดหลัก เช่นเดียวกับการบล็อก I/O อื่นๆ ที่ต้องการพอร์ตลูปเหตุการณ์ดังกล่าวไปยังเว็บ วิธีหนึ่งคือการเปลี่ยนโครงสร้างภายในแอปพลิเคชันเพื่อจัดการเหตุการณ์ I/O ในชุดข้อความแยกต่างหากและส่งต่อผลลัพธ์กลับไปที่เหตุการณ์หลัก อีกวิธีคือการใช้ Asyncify เพื่อหยุดลูปชั่วคราวและรอเหตุการณ์ในรูปแบบที่ไม่บล็อก
ฉันไม่ต้องการทำการเปลี่ยนแปลงที่สำคัญกับ libusb หรือ gPhoto2 และได้ใช้ Asyncify สำหรับการผสานรวม Promise
แล้ว นี่คือเส้นทางที่ฉันเลือก ในการจำลองรูปแบบการบล็อกของ poll()
สำหรับการพิสูจน์แนวคิดเบื้องต้น ฉันใช้ลูปดังที่แสดงด้านล่าง
#ifdef __EMSCRIPTEN__
// TODO: optimize this. Right now it will keep unwinding-rewinding the stack
// on each short sleep until an event comes or the timeout expires.
// We should probably create an actual separate thread that does signaling
// or come up with a custom event mechanism to report events from
// `usbi_signal_event` and process them here.
double until_time = emscripten_get_now() + timeout_ms;
do {
// Emscripten `poll` ignores timeout param, but pass 0 explicitly just
// in case.
num_ready = poll(fds, nfds, 0);
if (num_ready != 0) break;
// Yield to the browser event loop to handle events.
emscripten_sleep(0);
} while (emscripten_get_now() < until_time);
#else
num_ready = poll(fds, nfds, timeout_ms);
#endif
วัตถุประสงค์ของกรณีการใช้งานนี้
- เรียกใช้
poll()
เพื่อตรวจสอบว่าแบ็กเอนด์ยังรายงานเหตุการณ์ใดหรือไม่ หากมีบางลิงก์ การวนซ้ำจะหยุดลง มิเช่นนั้นการใช้งานpoll()
ของ Emscripten จะแสดงผลด้วย0
ทันที - โทรหา
emscripten_sleep(0)
ฟังก์ชันนี้ใช้ Asyncify และsetTimeout()
ในส่วนเบื้องหลัง และใช้ที่นี่เพื่อปล่อยการควบคุมกลับไปยังลูปเหตุการณ์ของเบราว์เซอร์หลัก ซึ่งจะช่วยให้เบราว์เซอร์จัดการการโต้ตอบของผู้ใช้และเหตุการณ์ I/O รวมถึง WebUSB ได้ - ตรวจสอบว่าระยะหมดเวลาที่ระบุยังไม่หมดอายุหรือไม่ และหากยังไม่สิ้นสุด ให้วนวนซ้ำ
ตามที่ความคิดเห็นกล่าวไว้ วิธีนี้ไม่ใช่วิธีที่ดีที่สุดเพราะยังคงบันทึกการเรียกใช้สแต็กทั้งชุดด้วย Asyncify แม้จะยังไม่มีเหตุการณ์ USB ที่ต้องจัดการ (ซึ่งเป็นโดยส่วนใหญ่) และ setTimeout()
เองก็มีระยะเวลาต่ำสุด 4 มิลลิวินาทีในเบราว์เซอร์สมัยใหม่ แต่ถึงกระนั้น ก็ยังทำได้ดีพอที่จะสร้างสตรีมแบบสดที่มีความละเอียด 13-14 FPS จาก DSLR ในการพิสูจน์แนวคิด
หลังจากนั้น ฉันตัดสินใจปรับปรุงฟีเจอร์นี้โดยใช้ประโยชน์จากระบบเหตุการณ์ของเบราว์เซอร์ มีหลายวิธีที่สามารถปรับปรุงการนำไปใช้นี้เพิ่มเติมได้ แต่สำหรับตอนนี้ฉันได้เลือกที่จะปล่อยเหตุการณ์ที่กำหนดเองโดยตรงในออบเจ็กต์ส่วนกลาง โดยไม่เชื่อมโยงกับโครงสร้างข้อมูล Libusb ที่เฉพาะเจาะจง วิธีดำเนินการดังกล่าวผ่านกลไกการรอและแจ้งเตือนต่อไปนี้ตามมาโคร EM_ASYNC_JS
EM_JS(void, em_libusb_notify, (void), {
dispatchEvent(new Event("em-libusb"));
});
EM_ASYNC_JS(int, em_libusb_wait, (int timeout), {
let onEvent, timeoutId;
try {
return await new Promise(resolve => {
onEvent = () => resolve(0);
addEventListener('em-libusb', onEvent);
timeoutId = setTimeout(resolve, timeout, -1);
});
} finally {
removeEventListener('em-libusb', onEvent);
clearTimeout(timeoutId);
}
});
ใช้ฟังก์ชัน em_libusb_notify()
ทุกครั้งที่ Libusb พยายามรายงานเหตุการณ์ เช่น การโอนข้อมูลเสร็จสมบูรณ์
void usbi_signal_event(usbi_event_t *event)
{
uint64_t dummy = 1;
ssize_t r;
r = write(EVENT_WRITE_FD(event), &dummy, sizeof(dummy));
if (r != sizeof(dummy))
usbi_warn(NULL, "event write failed");
#ifdef __EMSCRIPTEN__
em_libusb_notify();
#endif
}
ในขณะเดียวกัน ระบบจะใช้ส่วน em_libusb_wait()
เพื่อ "ตื่น" จากสลีปแบบอะซิงโครนัสเมื่อได้รับเหตุการณ์ em-libusb
หรือหมดเวลาแล้ว
double until_time = emscripten_get_now() + timeout_ms;
for (;;) {
// Emscripten `poll` ignores timeout param, but pass 0 explicitly just
// in case.
num_ready = poll(fds, nfds, 0);
if (num_ready != 0) break;
int timeout = until_time - emscripten_get_now();
if (timeout <= 0) break;
int result = em_libusb_wait(timeout);
if (result != 0) break;
}
การลดการนอนหลับและการปลุกระบบลดลงอย่างมาก กลไกนี้แก้ปัญหาด้านประสิทธิภาพของการใช้งานแบบ emscripten_sleep()
ก่อนหน้านี้ และเพิ่มอัตราการส่งข้อมูลสาธิตของ DSLR จาก 13-14 FPS เป็น 30 FPS ขึ้นไป ซึ่งเพียงพอสำหรับฟีดการถ่ายทอดสดที่ราบรื่น
สร้างระบบและการทดสอบแรก
หลังจากใช้แบ็กเอนด์เสร็จแล้ว ฉันต้องเพิ่มลงใน Makefile.am
และ configure.ac
สิ่งเดียวที่น่าสนใจตรงนี้คือการแก้ไข Flag สำหรับ Emscripten โดยเฉพาะ:
emscripten)
AC_SUBST(EXEEXT, [.html])
# Note: LT_LDFLAGS is not enough here because we need link flags for executable.
AM_LDFLAGS="${AM_LDFLAGS} --bind -s ASYNCIFY -s ASSERTIONS -s ALLOW_MEMORY_GROWTH -s INVOKE_RUN=0 -s EXPORTED_RUNTIME_METHODS=['callMain']"
;;
รูปแบบแรก โดยปกติไฟล์สั่งการในแพลตฟอร์ม Unix จะไม่มีนามสกุลไฟล์ อย่างไรก็ตาม Emscripten จะสร้างเอาต์พุตที่แตกต่างกัน โดยขึ้นอยู่กับส่วนขยายที่คุณขอ ฉันใช้ AC_SUBST(EXEEXT, …)
เพื่อเปลี่ยนส่วนขยายสั่งการเป็น .html
เพื่อให้ไฟล์สั่งการทั้งหมดภายในแพ็กเกจ (การทดสอบและตัวอย่าง) กลายเป็น HTML ที่มี Shell เริ่มต้นของ Emscripten ซึ่งดูแลการโหลดและเริ่มต้น JavaScript และ WebAssembly
อย่างที่ 2 เนื่องจากฉันใช้ Embind และ Asyncify ฉันต้องเปิดใช้ฟีเจอร์เหล่านั้น (--bind -s ASYNCIFY
) รวมถึงอนุญาตให้เพิ่มหน่วยความจำแบบไดนามิก (-s ALLOW_MEMORY_GROWTH
) ผ่านพารามิเตอร์ Linker ได้ด้วย ขออภัย ไม่มีวิธีที่ไลบรารีจะรายงานแฟล็กเหล่านั้นไปยัง Linker ได้ ดังนั้นทุกแอปพลิเคชันที่ใช้พอร์ต Libusb นี้จะต้องเพิ่มแฟล็ก Linker เดียวกันในการกำหนดค่าบิลด์ด้วย
สุดท้าย ดังที่กล่าวไว้ก่อนหน้านี้ WebUSB กำหนดให้การแจกแจงอุปกรณ์ต้องทำผ่านท่าทางสัมผัสของผู้ใช้ ตัวอย่างและการทดสอบของชุดเซลล์นั้นสามารถแจกแจงอุปกรณ์ได้เมื่อเริ่มระบบ และล้มเหลวโดยมีข้อผิดพลาดโดยไม่มีการเปลี่ยนแปลง แต่ต้องปิดใช้การดำเนินการอัตโนมัติ (-s INVOKE_RUN=0
) และแสดงเมธอด callMain()
ด้วยตนเอง (-s EXPORTED_RUNTIME_METHODS=...
) แทน
เมื่อทั้งหมดนี้เสร็จสิ้น เราสามารถแสดงไฟล์ที่สร้างขึ้นด้วยเว็บเซิร์ฟเวอร์แบบคงที่ เริ่มต้น WebUSB และเรียกใช้ไฟล์ปฏิบัติการ HTML เหล่านั้นด้วยตนเองโดยอาศัยความช่วยเหลือจากเครื่องมือสำหรับนักพัฒนาเว็บ
ถึงจะไม่ได้มีอะไรมากเท่าไหร่นัก แต่เมื่อย้ายไลบรารีไปยังแพลตฟอร์มใหม่ การได้อยู่ในขั้นตอนที่สร้างเอาต์พุตที่ถูกต้องเป็นครั้งแรกนั้นเป็นเรื่องที่น่าตื่นเต้นมาก
การใช้พอร์ต
ตามที่ได้กล่าวไว้ข้างต้น พอร์ตจะขึ้นอยู่กับฟีเจอร์บางอย่างของ Emscripten ที่ปัจจุบันต้องเปิดใช้ในขั้นตอนการลิงก์ของแอปพลิเคชัน หากต้องการใช้พอร์ต Libusb นี้ในแอปพลิเคชันของคุณเอง คุณจะต้องดำเนินการต่อไปนี้
- ดาวน์โหลด libusb ล่าสุดเป็นที่เก็บถาวรเป็นส่วนหนึ่งของบิลด์ หรือเพิ่มเป็นโมดูลย่อยของ git ในโปรเจ็กต์ของคุณ
- เรียกใช้
autoreconf -fiv
ในโฟลเดอร์libusb
- เรียกใช้
emconfigure ./configure –host=wasm32 –prefix=/some/installation/path
เพื่อเริ่มต้นโปรเจ็กต์สำหรับการคอมไพล์แบบข้ามระบบ และเพื่อกำหนดเส้นทางที่ต้องการวางอาร์ติแฟกต์ที่สร้างขึ้น - เรียกใช้
emmake make install
- ชี้แอปพลิเคชันหรือไลบรารีระดับสูงกว่าเพื่อค้นหา libusb ภายใต้เส้นทางที่เลือกไว้ก่อนหน้านี้
- เพิ่มแฟล็กต่อไปนี้ในอาร์กิวเมนต์ลิงก์ของแอปพลิเคชัน:
--bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH
ปัจจุบันไลบรารีมีข้อจำกัดบางประการดังนี้
- ไม่มีการสนับสนุนการยกเลิกการโอน นี่ถือเป็นข้อจำกัดของ WebUSB ซึ่งมาจากการไม่มีการยกเลิกการโอนข้ามแพลตฟอร์มใน Libusb
- ไม่รองรับการโอนแบบอิสระ คุณไม่ควรเพิ่มได้ง่ายๆ ด้วยการทำตามโหมดการโอนที่มีอยู่เพื่อเป็นตัวอย่าง แต่ก็เป็นโหมดที่ค่อนข้างหายากเช่นกัน และเราไม่มีอุปกรณ์ที่จะทดสอบอุปกรณ์ ตอนนี้ก็เลยไม่ได้รับการสนับสนุน หากคุณมีอุปกรณ์ดังกล่าวและต้องการมีส่วนร่วมในห้องสมุด เรายินดีรับการประชาสัมพันธ์!
- ก่อนหน้านี้พูดถึงข้อจำกัดข้ามแพลตฟอร์ม ระบบปฏิบัติการกำหนดข้อจำกัดเหล่านี้ เราจึงทำได้ไม่มาก ยกเว้นการขอให้ผู้ใช้ลบล้างไดรเวอร์หรือสิทธิ์ อย่างไรก็ตาม หากคุณกำลังพอร์ต HID หรืออุปกรณ์ซีเรียล คุณสามารถทำตามตัวอย่าง libusb และพอร์ตไลบรารีอื่นไปยัง Fugu API ตัวอื่น ตัวอย่างเช่น คุณสามารถพอร์ตไลบรารี C hidapi ไปยัง WebHID และดำเนินการแก้ปัญหาเหล่านั้นที่เกี่ยวข้องกับการเข้าถึง USB ในระดับต่ำได้
บทสรุป
ในโพสต์นี้ ผมจะแสดงวิธีการด้วยความช่วยเหลือจาก API แบบ Emscripten, Asyncify และ Fugu แม้แต่ไลบรารีระดับต่ำอย่าง Libusb ก็สามารถถ่ายโอนไปยังเว็บได้ด้วยเทคนิคการผสานรวมเล็กๆ น้อยๆ
การพอร์ตไลบรารีระดับต่ำที่จำเป็นและใช้กันอย่างแพร่หลายดังกล่าวจะมีผลตอบแทนคุ้มค่าอย่างยิ่ง เพราะจะทำให้นำไลบรารีระดับสูงขึ้นหรือแม้แต่แอปพลิเคชันทั้งหมดมาไว้บนเว็บได้ด้วย ซึ่งเปิดประสบการณ์ที่ก่อนหน้านี้จำกัดไว้สำหรับผู้ใช้เพียง 1 หรือ 2 แพลตฟอร์ม ต่ออุปกรณ์และระบบปฏิบัติการทุกประเภท ทำให้เข้าถึงประสบการณ์เหล่านี้ได้ด้วยการคลิกเพียงครั้งเดียว
ในโพสต์ถัดไป เราจะแนะนำขั้นตอนที่เกี่ยวข้องกับการสร้างเดโม gPhoto2 บนเว็บ ซึ่งไม่เพียงแต่เรียกข้อมูลอุปกรณ์เท่านั้น แต่ยังใช้ฟีเจอร์การโอนของ libusb อย่างครอบคลุมด้วย ในขณะเดียวกัน เราหวังว่าคุณจะพบตัวอย่างหนังสือที่สร้างแรงบันดาลใจและลองใช้การสาธิต เล่นกับไลบรารี หรืออาจลองย้ายไลบรารีอื่นที่ใช้กันอย่างแพร่หลายไปยัง Fugu API ตัวใดตัวหนึ่งเลยก็ได้