Aprende cómo el código que interactúa con dispositivos externos se puede portar a la Web con las APIs de WebAssembly y Fugu.
En una publicación anterior, mostré cómo portar apps que usan APIs de sistemas de archivos a la Web con la API de File System Access, WebAssembly y Asyncify. Ahora quiero continuar con el mismo tema de integrar las APIs de Fugu con WebAssembly y portar apps a la Web sin perder funciones importantes.
Te mostraré cómo las apps que se comunican con dispositivos USB se pueden portar a la Web transfiriendo libusb, una biblioteca USB popular escrita en C, a WebAssembly (a través de Emscripten), Asyncify y WebUSB.
Primero lo primero: una demostración
Lo más importante que debes hacer cuando portas una biblioteca es elegir la demo correcta, algo que muestre las capacidades de la biblioteca portada, te permita probarla de diferentes maneras y sea visualmente atractiva al mismo tiempo.
La idea que elegí fue el control remoto de DSLR. En particular, un proyecto de código abierto gPhoto2 lleva suficiente tiempo en este espacio como para realizar ingeniería inversa e implementar compatibilidad con una amplia variedad de cámaras digitales. Admite varios protocolos, pero el que más me interesaba era la compatibilidad con USB, que realiza a través de libusb.
Describiré los pasos para compilar esta demostración en dos partes. En esta entrada de blog, describiré cómo porté libusb y qué trucos podrían ser necesarios para portar otras bibliotecas populares a las APIs de Fugu. En la segunda publicación, explicaré en detalle la portabilidad y la integración de gPhoto2.
Al final, obtuve una aplicación web en funcionamiento que muestra una vista previa del feed en vivo de una DSLR y puede controlar su configuración a través de USB. No dudes en consultar la demostración en vivo o pregrabada antes de leer los detalles técnicos:
Nota sobre las peculiaridades específicas de la cámara
Es posible que hayas notado que cambiar la configuración lleva un tiempo en el video. Al igual que con la mayoría de los otros problemas que podrías ver, esto no se debe al rendimiento de WebAssembly o WebUSB, sino a la forma en que gPhoto2 interactúa con la cámara específica elegida para la demostración.
La Sony a6600 no expone una API para establecer valores como ISO, apertura o velocidad de obturador directamente, sino que solo proporciona comandos para aumentarlos o disminuirlos en la cantidad de pasos especificada. Para complicar aún más las cosas, tampoco muestra una lista de los valores admitidos en realidad, ya que la lista que se muestra parece estar codificada en muchos modelos de cámaras Sony.
Cuando se establece uno de esos valores, gPhoto2 no tiene otra opción que hacer lo siguiente:
- Da un paso (o varios) en la dirección del valor elegido.
- Espera a que la cámara actualice la configuración.
- Vuelve a leer el valor en el que se detuvo la cámara.
- Verifica que el último paso no haya omitido el valor deseado ni se haya unido al final o al principio de la lista.
- Y todo de nuevo.
Esto puede tardar un poco, pero si la cámara admite el valor, llegará a él y, si no, se detendrá en el valor compatible más cercano.
Es probable que otras cámaras tengan diferentes parámetros de configuración, APIs subyacentes y peculiaridades. Ten en cuenta que gPhoto2 es un proyecto de código abierto y que no es posible realizar pruebas automatizadas ni manuales de todos los modelos de cámaras disponibles. Por lo tanto, siempre se aceptan los informes de problemas y las PR detalladas (pero asegúrate de reproducir los problemas primero con el cliente oficial de gPhoto2).
Notas importantes sobre la compatibilidad multiplataforma
Lamentablemente, en Windows, a cualquier dispositivo “conocido”, incluidas las cámaras réflex digitales, se le asigna un controlador del sistema que no es compatible con WebUSB. Si quieres probar la demostración en Windows, deberás usar una herramienta como Zadig para anular el controlador de la DSLR conectada a WinUSB o libusb. Este enfoque funciona bien para mí y muchos otros usuarios, pero debes usarlo bajo tu propia responsabilidad.
En Linux, es probable que debas configurar permisos personalizados para permitir el acceso a tu DSLR a través de WebUSB, aunque esto depende de tu distribución.
En macOS y Android, la demostración debería funcionar de inmediato. Si lo pruebas en un teléfono Android, asegúrate de cambiar al modo horizontal, ya que no me esforcé mucho en que sea responsivo (se aceptan PRs):
Para obtener una guía más detallada sobre el uso multiplataforma de WebUSB, consulta la sección “Consideraciones específicas de la plataforma” de “Cómo compilar un dispositivo para WebUSB”.
Cómo agregar un backend nuevo a libusb
Ahora, veamos los detalles técnicos. Si bien es posible proporcionar una API de shim similar a libusb (otros lo hicieron antes) y vincular otras aplicaciones con ella, este enfoque es propenso a errores y dificulta cualquier extensión o mantenimiento adicional. Quería hacer las cosas bien, de una manera que pudiera contribuir en upstream y fusionarse en libusb en el futuro.
Por suerte, el archivo readme de libusb dice lo siguiente:
“libusb se abstrae de forma interna de manera tal que se pueda portar a otros sistemas operativos. Consulta el archivo PORTING para obtener más información”.
libusb está estructurado de manera tal que la API pública está separada de los "backends". Esos backends son responsables de enumerar, abrir, cerrar y comunicarse con los dispositivos a través de las APIs de bajo nivel del sistema operativo. De esta manera, libusb ya abstrae las diferencias entre Linux, macOS, Windows, Android, OpenBSD/NetBSD, Haiku y Solaris, y funciona en todas estas plataformas.
Lo que tuve que hacer fue agregar otro backend para el “sistema operativo” Emscripten+WebUSB. Las implementaciones de esos backends se encuentran en la carpeta 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
Cada backend incluye el encabezado libusbi.h con tipos y ayudantes comunes, y debe exponer una variable usbi_backend de tipo usbi_os_backend. Por ejemplo, así se ve el backend de 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),
};
Si observamos las propiedades, podemos ver que la estructura incluye el nombre del backend, un conjunto de sus capacidades, controladores para varias operaciones USB de bajo nivel en forma de punteros de función y, por último, los tamaños que se asignarán para almacenar datos privados a nivel del dispositivo, del contexto o de la transferencia.
Los campos de datos privados son útiles, al menos, para almacenar controladores del SO para todos esos elementos, ya que, sin controladores, no sabemos a qué elemento se aplica una operación determinada. En la implementación web, los controladores del SO serían los objetos de JavaScript de WebUSB subyacentes. La forma natural de representarlos y almacenarlos en Emscripten es a través de la clase emscripten::val, que se proporciona como parte de Embind (el sistema de vinculaciones de Emscripten).
La mayoría de los backends de la carpeta se implementan en C, pero algunos se implementan en C++. Embind solo funciona con C++, por lo que tomé la decisión por ti y agregué libusb/libusb/os/emscripten_webusb.cpp con la estructura requerida y con sizeof(val) para los campos de datos privados:
#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),
};
Almacenamiento de objetos WebUSB como controladores de dispositivos
libusb proporciona punteros listos para usar al área asignada para datos privados. Para trabajar con esos punteros como instancias de val, agregué pequeños ayudantes que los construyen en su lugar, los recuperan como referencias y mueven los valores:
// 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))) {}
};
APIs web asíncronas en contextos C síncronos
Ahora se necesita una forma de controlar las APIs de WebUSB asíncronas en las que libusb espera operaciones síncronas. Para ello, podría usar Asyncify o, más específicamente, su integración de Embind a través de val::await().
También quería controlar correctamente los errores de WebUSB y convertirlos en códigos de error de libusb, pero actualmente Embind no tiene forma de controlar las excepciones de JavaScript ni los rechazos de Promise desde el lado de C++. Para resolver este problema, captura un rechazo en el lado de JavaScript y convierte el resultado en un objeto { error, value } que ahora se puede analizar de forma segura desde el lado de C++. Hice esto con una combinación de la macro EM_JS y las APIs de Emval.to{Handle, Value}:
EM_JS(EM_VAL, em_promise_catch_impl, (EM_VAL handle), {
let promise = Emval.toValue(handle);
promise = promise.then(
value => ({