广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何使用jQuery封装WebUSB API实现浏览器直接读写USB设备?

浏览器访问USB设备曾经是安全策略的禁区,但随着物联网与硬件交互需求的增长,Chrome率先在桌面端提供了WebUSB API,允许网页在用户授权后直接与USB外设通信。对于仍大量使用jQuery的存量项目而言,直接操作Promise链会显得繁琐,将WebUSB封装成jQuery插件能够显著简化调用逻辑。本文将介绍如何利用jQuery封装WebUSB API,实现连接设备、读取数据、写入指令的完整交互流程。

如何使用jQuery封装WebUSB API实现浏览器直接读写USB设备?

WebUSB API基础与浏览器支持情况

WebUSB是W3C标准草案,定义了网页与USB设备进行安全通信的接口。它基于USB协议栈,允许网页声明特定接口、发送控制传输和批量传输。核心对象包括navigator.usb、USBDevice、USBConfiguration、USBInterface、USBEndpoint等。调用流程通常为:调用navigator.usb.requestDevice弹出设备选择器,获取用户授权的USBDevice实例,然后打开设备、选择配置、声明接口,最后通过transferOut和transferIn方法进行数据传输。

需要注意的是,WebUSB目前仅在基于Chromium的浏览器中得到支持,Firefox和Safari暂未实现。此外,页面必须运行在HTTPS环境或localhost下,且requestDevice调用必须由用户手势触发(如点击按钮)。这些限制意味着封装层需要处理不支持WebUSB时的降级提示,以及手势触发的约束。

理解这些底层细节后,我们才能设计出健壮的jQuery封装。jQuery的核心优势在于其简洁的API风格和事件系统,我们可以将WebUSB的异步流程包装成类似$.ajax的调用方式,同时利用Deferred对象管理Promise链。

封装jQuery插件的设计思路与基本结构

封装的目标是将常用的USB操作抽象为几个易用的方法,例如:$.webusb.connect(filter)用于连接设备,$.webusb.write(data)用于发送数据,$.webusb.read(length)用于接收数据。由于WebUSB的操作依赖设备实例,我们可以在插件内部维护一个当前连接的设备对象,并提供设置过滤规则的方法。

为了避免全局状态污染,可以使用闭包保存私有变量,并通过jQuery的$.extend暴露公共方法。以下是一个基本的封装骨架:

(function($) {
    var currentDevice = null;
    var currentInterface = null;
    var currentEndpointOut = null;
    var currentEndpointIn = null;
    var webusb = {
        isSupported: function() {
            return 'usb' in navigator;
        },
        connect: function(filter) {
            var deferred = $.Deferred();
            if (!this.isSupported()) {
                deferred.reject('WebUSB not supported');
                return deferred.promise();
            }
            navigator.usb.requestDevice({ filters: [filter] })
            .then(function(device) {
                currentDevice = device;
                return device.open();
            })
            .then(function() {
                if (currentDevice.configuration === null) {
                    return currentDevice.selectConfiguration(1);
                }
            })
            .then(function() {
                return currentDevice.claimInterface(0);
            })
            .then(function(usbInterface) {
                currentInterface = usbInterface;
                // 查找批量传输端点
                usbInterface.alternate.endpoints.forEach(function(endpoint) {
                    if (endpoint.direction === 'out') {
                        currentEndpointOut = endpoint;
                    } else if (endpoint.direction === 'in') {
                        currentEndpointIn = endpoint;
                    }
                });
                deferred.resolve(currentDevice);
            })
            .catch(function(error) {
                deferred.reject(error);
            });
            return deferred.promise();
        },
        write: function(data) {
            var deferred = $.Deferred();
            if (!currentDevice || !currentEndpointOut) {
                deferred.reject('Device not connected');
                return deferred.promise();
            }
            currentDevice.transferOut(currentEndpointOut.endpointNumber, data)
            .then(function(result) {
                deferred.resolve(result);
            })
            .catch(function(error) {
                deferred.reject(error);
            });
            return deferred.promise();
        },
        read: function(length) {
            var deferred = $.Deferred();
            if (!currentDevice || !currentEndpointIn) {
                deferred.reject('Device not connected');
                return deferred.promise();
            }
            currentDevice.transferIn(currentEndpointIn.endpointNumber, length)
            .then(function(result) {
                deferred.resolve(result);
            })
            .catch(function(error) {
                deferred.reject(error);
            });
            return deferred.promise();
        },
        close: function() {
            if (currentDevice) {
                currentDevice.close();
                currentDevice = null;
                currentInterface = null;
                currentEndpointOut = null;
                currentEndpointIn = null;
            }
        }
    };
    $.webusb = webusb;
})(jQuery);

上述代码展示了基本的封装思路。connect方法接收一个过滤器对象,该对象用于匹配特定厂商ID或产品ID。在成功连接后,我们查找接口0的批量端点并保存引用,之后write和read方法便可以直接调用。使用$.Deferred可以保持异步操作的jQuery风格,方便在调用时使用done和fail回调。

处理设备过滤、批量传输与错误捕获

在实际应用中,设备过滤规则非常关键。WebUSB的requestDevice支持两种过滤方式:按厂商ID和产品ID过滤,或者按设备类别过滤。过滤器对象的形式如{ vendorId: 0x1234, productId: 0x5678 }。如果设备没有特定产品ID,可以使用{ vendorId: 0x1234 }。封装时应当允许调用者传入自定义过滤规则,并保留扩展性。

批量传输是USB通信中最常用的方式,适用于数据量较大且对实时性要求不高的场景。transferOut用于向设备发送数据,第一个参数是端点号,第二个参数是BufferSource类型的数据(如Uint8Array)。transferIn用于从设备读取数据,需要指定期望读取的字节数。需要注意的是,端点号从USB描述符中获取,通常在claimInterface后通过端点列表查找。如果设备有多个接口或多个端点,封装时需要增加参数指定接口号和端点方向。

错误处理也是封装必须考虑的部分。原生Promise的catch只能捕获异步操作中的错误,但用户取消设备选择器时会抛出NotFoundError,设备拔出时会触发disconnect事件。我们可以在插件中增加对navigator.usb的connect和disconnect事件监听,当设备断开时自动清理当前状态,并触发自定义事件通知页面。以下代码展示了如何处理设备断开:

navigator.usb.addEventListener('disconnect', function(event) {
    if (currentDevice && event.device === currentDevice) {
        currentDevice = null;
        currentInterface = null;
        currentEndpointOut = null;
        currentEndpointIn = null;
        $(document).trigger('webusb:disconnect');
    }
});

通过监听disconnect事件,我们可以及时更新UI状态,提示用户设备已拔出。同时,在connect方法中应当增加设备已连接时的检查,避免重复打开已关闭的设备。

实际案例:读写自定义USB HID设备

假设我们有一个自定义USB HID设备,厂商ID为0x1234,产品ID为0x5678,接口0包含一个输出端点(端点号1)和一个输入端点(端点号1,方向相反)。设备协议规定,发送3字节指令[0x01, 0x02, 0x03]后,设备会返回4字节状态数据。使用我们封装的jQuery插件,页面代码可以非常简洁:

<button id="connectBtn">连接设备</button>
<button id="sendBtn" disabled>发送指令</button>
<div id="status"></div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="jquery.webusb.js"></script>
<script>
$(function() {
    var filter = { vendorId: 0x1234, productId: 0x5678 };
    $('#connectBtn').on('click', function() {
        $.webusb.connect(filter).done(function(device) {
            $('#status').text('设备已连接:' + device.productName);
            $('#sendBtn').prop('disabled', false);
        }).fail(function(error) {
            $('#status').text('连接失败:' + error);
        });
    });
    $('#sendBtn').on('click', function() {
        var command = new Uint8Array([0x01, 0x02, 0x03]);
        $.webusb.write(command).done(function(result) {
            console.log('发送成功,已写入', result.bytesWritten, '字节');
            // 等待设备响应
            $.webusb.read(4).done(function(readResult) {
                var response = new Uint8Array(readResult.data.buffer);
                $('#status').text('收到响应:' + Array.from(response).join(','));
            }).fail(function(error) {
                $('#status').text('读取失败:' + error);
            });
        }).fail(function(error) {
            $('#status').text('发送失败:' + error);
        });
    });
    $(document).on('webusb:disconnect', function() {
        $('#status').text('设备已断开');
        $('#sendBtn').prop('disabled', true);
    });
});
</script>

这个示例展示了完整的连接、写入、读取流程。我们把设备过滤规则作为参数传入connect方法,成功回调中拿到设备信息,然后启用发送按钮。发送指令使用Uint8Array构造二进制数据,写入后立即调用read方法读取响应。整个过程基于jQuery的Deferred和事件机制,符合传统jQuery开发者的使用习惯。

需要注意的是,read方法中readResult.data是DataView类型,需要通过其buffer属性获取底层ArrayBuffer,再转换为Uint8Array以便处理。如果设备返回的数据长度不固定,可以循环读取直到满足协议要求。

安全限制与生产环境注意事项

WebUSB的安全模型非常严格,浏览器只允许在安全上下文(HTTPS或localhost)中访问,并且requestDevice必须由用户手势触发。这意味着你不能在页面加载时自动弹出设备选择器,必须绑定到点击、触摸等事件上。此外,用户每次重新加载页面后都需要重新授权,浏览器不会记住设备权限(除非用户勾选“允许此站点访问”)。

在封装时,应当检测环境是否支持WebUSB,如果不支持则提供优雅降级,例如提示用户使用Chrome浏览器或改用其他通信方式(如串口API)。同时,封装层不应缓存设备实例到全局窗口对象上,以免页面刷新后引用失效。除了设备断开事件,还需要处理连接超时、传输中断等异常情况。

另一个实际问题是设备驱动的冲突。如果操作系统已经为USB设备安装了专用驱动,浏览器可能无法声明接口。此时需要在设备固件中配置为无驱模式或使用WinUSB驱动。对于HID设备,由于操作系统默认会占用HID接口,WebUSB无法直接访问,需要设备固件提供额外的非HID接口。这些都是硬件设计阶段需要考虑的因素。

最后,虽然jQuery封装降低了使用门槛,但开发者仍需理解WebUSB的底层概念,特别是端点、接口和配置的关系。只有在明确设备通信协议的前提下,封装才能发挥最大作用。

通过本文的封装实践,我们看到了jQuery与WebUSB结合的可能性。在存量项目中使用这种封装方式,可以以较小的改动成本实现浏览器直接读写USB设备的功能,为工业调试、硬件配置工具等场景提供便利。

广告位 · 文章底部(后台-模块管理替换此区域)