JS `WebUSB` `Device Descriptors` 解析与自定义 USB 设备通信

各位观众老爷,大家好!今天咱们来聊聊一个听起来有点高大上,但实际上挺有趣的技术——WebUSB,以及如何用它来解析USB设备描述符,并最终与你的自定义USB设备谈笑风生(进行通信)。

一、WebUSB:浏览器里的USB魔法

WebUSB,顾名思义,就是让你的Web应用直接和USB设备交流的API。想想看,以前要搞定USB设备,要么得装驱动,要么得写C/C++代码,现在好了,浏览器直接上,简直是懒人福音啊!

WebUSB的优势:

  • 跨平台: 只要浏览器支持,你的代码就能在Windows、macOS、Linux上跑。
  • 无需驱动: 对于符合WebUSB规范的设备,不需要安装额外的驱动程序。
  • 安全: 浏览器会询问用户是否允许Web应用访问USB设备,安全性有保障。
  • 方便: 直接在浏览器里调试,省去了很多麻烦。

但是,WebUSB也有局限性:

  • 浏览器支持: 目前主流浏览器都支持,但还是要注意兼容性。
  • 设备支持: 并非所有USB设备都支持WebUSB。设备需要声明支持WebUSB协议。
  • 安全性: 虽然有安全机制,但开发者仍需注意代码安全,防止恶意利用。

二、USB设备描述符:设备的身份证

想要和USB设备通信,首先你得了解它。USB设备描述符就像是设备的身份证,它包含了设备的各种信息,比如厂商ID(Vendor ID)、产品ID(Product ID)、设备类别等等。

常见的USB描述符:

描述符类型 描述
Device Descriptor 设备总体信息,如VID、PID等。
Configuration Descriptor 设备配置信息,如接口数量等。
Interface Descriptor 接口信息,如接口类别、端点数量等。
Endpoint Descriptor 端点信息,如端点地址、传输类型等。

Device Descriptor (设备描述符) 结构体:

typedef struct {
  uint8_t  bLength;        // 描述符长度,固定为0x12
  uint8_t  bDescriptorType; // 描述符类型,Device Descriptor为0x01
  uint16_t bcdUSB;         // USB版本号,如0x0200表示USB 2.0
  uint8_t  bDeviceClass;   // 设备类别,如0x00表示由接口描述符定义
  uint8_t  bDeviceSubClass;  // 设备子类别
  uint8_t  bDeviceProtocol;  // 设备协议
  uint8_t  bMaxPacketSize0;  // 端点0的最大包大小
  uint16_t idVendor;       // 厂商ID (Vendor ID)
  uint16_t idProduct;      // 产品ID (Product ID)
  uint16_t bcdDevice;       // 设备版本号
  uint8_t  iManufacturer;  // 厂商字符串索引
  uint8_t  iProduct;       // 产品字符串索引
  uint8_t  iSerialNumber;  // 序列号字符串索引
  uint8_t  bNumConfigurations; // 配置数量
} USB_DeviceDescriptor;

Configuration Descriptor (配置描述符) 结构体:

typedef struct {
  uint8_t  bLength;            // 描述符长度,固定为0x09
  uint8_t  bDescriptorType;    // 描述符类型,Configuration Descriptor为0x02
  uint16_t wTotalLength;       // 描述符总长度(包括所有接口、端点等描述符)
  uint8_t  bNumInterfaces;       // 接口数量
  uint8_t  bConfigurationValue; // 配置值,用于选择配置
  uint8_t  iConfiguration;      // 配置字符串索引
  uint8_t  bmAttributes;        // 属性,如是否支持远程唤醒、供电方式等
  uint8_t  bMaxPower;           // 最大功耗,单位为2mA
} USB_ConfigurationDescriptor;

Interface Descriptor (接口描述符) 结构体:

typedef struct {
  uint8_t  bLength;         // 描述符长度,固定为0x09
  uint8_t  bDescriptorType; // 描述符类型,Interface Descriptor为0x04
  uint8_t  bInterfaceNumber; // 接口编号,从0开始
  uint8_t  bAlternateSetting; // 备用接口设置
  uint8_t  bNumEndpoints;    // 端点数量
  uint8_t  bInterfaceClass;   // 接口类别,如0x03表示HID设备
  uint8_t  bInterfaceSubClass; // 接口子类别
  uint8_t  bInterfaceProtocol; // 接口协议
  uint8_t  iInterface;      // 接口字符串索引
} USB_InterfaceDescriptor;

Endpoint Descriptor (端点描述符) 结构体:

typedef struct {
  uint8_t  bLength;         // 描述符长度,固定为0x07
  uint8_t  bDescriptorType; // 描述符类型,Endpoint Descriptor为0x05
  uint8_t  bEndpointAddress; // 端点地址,bit7表示方向(0: OUT, 1: IN)
  uint8_t  bmAttributes;    // 属性,如传输类型(控制、批量、中断、等时)
  uint16_t wMaxPacketSize;  // 最大包大小
  uint8_t  bInterval;       // 轮询间隔,单位取决于传输类型
} USB_EndpointDescriptor;

三、WebUSB实战:解析描述符并通信

1. HTML准备:

首先,我们需要一个简单的HTML页面,包含一个按钮,点击后触发WebUSB连接:

<!DOCTYPE html>
<html>
<head>
    <title>WebUSB Demo</title>
</head>
<body>
    <button id="connectButton">Connect to USB Device</button>
    <pre id="output"></pre>
    <script src="script.js"></script>
</body>
</html>

2. JavaScript代码:

接下来,是核心的JavaScript代码 script.js

const connectButton = document.getElementById('connectButton');
const output = document.getElementById('output');

let device;

connectButton.addEventListener('click', async () => {
    try {
        // 请求设备
        device = await navigator.usb.requestDevice({ filters: [] });

        // 连接设备
        await device.open();

        // 选择配置
        await device.selectConfiguration(1); // 假设选择第一个配置

        // 声明接口(如果需要)
        await device.claimInterface(0); // 假设声明第一个接口

        log('Connected to USB device!');

        // 读取设备描述符
        const deviceDescriptor = await getDeviceDescriptor(device);
        log('Device Descriptor:', deviceDescriptor);

        // 读取配置描述符
        const configurationDescriptor = await getConfigurationDescriptor(device);
        log('Configuration Descriptor:', configurationDescriptor);

        // 假设设备是 HID 设备,发送一个特征报告请求
        if (deviceDescriptor.bDeviceClass === 0x00 && configurationDescriptor.interfaces[0].interfaceClass === 0x03) {
            try {
                const featureReport = await getFeatureReport(device, 0x01, 64); // 假设报告ID为 0x01,长度为 64
                log('Feature Report:', featureReport);
            } catch (error) {
                log('Error getting feature report:', error);
            }
        }

        // 发送数据 (OUT endpoint)
        const dataToSend = new Uint8Array([0x01, 0x02, 0x03, 0x04]); // 要发送的数据
        try {
            await device.transferOut(1, dataToSend); // 假设 OUT 端点地址为 1
            log('Data sent successfully!');
        } catch (error) {
            log('Error sending data:', error);
        }

        // 接收数据 (IN endpoint)
        try {
            const result = await device.transferIn(2, 64); // 假设 IN 端点地址为 2, 最大包大小为 64
            const receivedData = new Uint8Array(result.data.buffer);
            log('Received data:', receivedData);
        } catch (error) {
            log('Error receiving data:', error);
        }

    } catch (error) {
        log('Error:', error);
    } finally {
        // 关闭设备 (可选,根据需求)
        // if (device && device.opened) {
        //     await device.close();
        //     log('Device closed.');
        // }
    }
});

// 日志函数
function log(message, data = null) {
    if (data) {
        output.textContent += message + JSON.stringify(data, null, 2) + 'n';
    } else {
        output.textContent += message + 'n';
    }
}

// 获取设备描述符
async function getDeviceDescriptor(device) {
    const buffer = await device.controlTransferIn({
        requestType: 'standard',
        recipient: 'device',
        request: 0x06, // GET_DESCRIPTOR
        value: (0x01 << 8) | 0x00, // Device Descriptor type
        index: 0
    }, 18); // 设备描述符长度为18字节

    const dataView = new DataView(buffer.data.buffer);

    return {
        bLength: dataView.getUint8(0),
        bDescriptorType: dataView.getUint8(1),
        bcdUSB: dataView.getUint16(2, true),
        bDeviceClass: dataView.getUint8(4),
        bDeviceSubClass: dataView.getUint8(5),
        bDeviceProtocol: dataView.getUint8(6),
        bMaxPacketSize0: dataView.getUint8(7),
        idVendor: dataView.getUint16(8, true),
        idProduct: dataView.getUint16(10, true),
        bcdDevice: dataView.getUint16(12, true),
        iManufacturer: dataView.getUint8(14),
        iProduct: dataView.getUint8(15),
        iSerialNumber: dataView.getUint8(16),
        bNumConfigurations: dataView.getUint8(17)
    };
}

// 获取配置描述符
async function getConfigurationDescriptor(device) {
    const buffer = await device.controlTransferIn({
        requestType: 'standard',
        recipient: 'device',
        request: 0x06, // GET_DESCRIPTOR
        value: (0x02 << 8) | 0x00, // Configuration Descriptor type, index 0
        index: 0
    }, 9); // 先读取前9个字节,获取wTotalLength

    const dataView = new DataView(buffer.data.buffer);
    const wTotalLength = dataView.getUint16(2, true);

    const fullBuffer = await device.controlTransferIn({
        requestType: 'standard',
        recipient: 'device',
        request: 0x06, // GET_DESCRIPTOR
        value: (0x02 << 8) | 0x00, // Configuration Descriptor type, index 0
        index: 0
    }, wTotalLength); // 读取完整配置描述符

    const fullDataView = new DataView(fullBuffer.data.buffer);
    const numInterfaces = fullDataView.getUint8(4);
    const interfaces = [];

    let offset = 9; // 跳过Configuration Descriptor
    for (let i = 0; i < numInterfaces; i++) {
        const interfaceLength = fullDataView.getUint8(offset);
        const interfaceDescriptorType = fullDataView.getUint8(offset + 1);

        if (interfaceDescriptorType === 0x04) { // Interface Descriptor
            const interfaceDescriptor = {
                bLength: fullDataView.getUint8(offset),
                bDescriptorType: fullDataView.getUint8(offset + 1),
                bInterfaceNumber: fullDataView.getUint8(offset + 2),
                bAlternateSetting: fullDataView.getUint8(offset + 3),
                bNumEndpoints: fullDataView.getUint8(offset + 4),
                bInterfaceClass: fullDataView.getUint8(offset + 5),
                bInterfaceSubClass: fullDataView.getUint8(offset + 6),
                bInterfaceProtocol: fullDataView.getUint8(offset + 7),
                iInterface: fullDataView.getUint8(offset + 8),
                endpoints: []
            };

            offset += interfaceLength;

            for (let j = 0; j < interfaceDescriptor.bNumEndpoints; j++) {
                const endpointLength = fullDataView.getUint8(offset);
                const endpointDescriptorType = fullDataView.getUint8(offset + 1);

                if (endpointDescriptorType === 0x05) { // Endpoint Descriptor
                    const endpointDescriptor = {
                        bLength: fullDataView.getUint8(offset),
                        bDescriptorType: fullDataView.getUint8(offset + 1),
                        bEndpointAddress: fullDataView.getUint8(offset + 2),
                        bmAttributes: fullDataView.getUint8(offset + 3),
                        wMaxPacketSize: fullDataView.getUint16(offset + 4, true),
                        bInterval: fullDataView.getUint8(offset + 6)
                    };
                    interfaceDescriptor.endpoints.push(endpointDescriptor);
                    offset += endpointLength;
                } else {
                    // 如果不是 Endpoint Descriptor,则跳过
                    offset += fullDataView.getUint8(offset);
                    j--; // 修正循环计数器,因为没有找到端点
                }
            }
            interfaces.push(interfaceDescriptor);
        } else {
            // 如果不是 Interface Descriptor,则跳过
            offset += fullDataView.getUint8(offset);
            i--;  // 修正循环计数器,因为没有找到接口
        }
    }

    return {
        bLength: fullDataView.getUint8(0),
        bDescriptorType: fullDataView.getUint8(1),
        wTotalLength: wTotalLength,
        bNumInterfaces: numInterfaces,
        bConfigurationValue: fullDataView.getUint8(5),
        iConfiguration: fullDataView.getUint8(6),
        bmAttributes: fullDataView.getUint8(7),
        bMaxPower: fullDataView.getUint8(8),
        interfaces: interfaces
    };
}

// 获取 Feature Report (HID 设备)
async function getFeatureReport(device, reportId, length) {
    const buffer = await device.controlTransferIn({
        requestType: 'class',
        recipient: 'interface',
        request: 0x01, // GET_REPORT
        value: (0x03 << 8) | reportId, // Feature Report type and Report ID
        index: 0
    }, length);

    return new Uint8Array(buffer.data.buffer);
}

代码解释:

  • navigator.usb.requestDevice(): 弹出设备选择框,让用户选择USB设备。
  • device.open(): 打开设备连接。
  • device.selectConfiguration(): 选择设备配置。
  • device.claimInterface(): 声明接口,告诉系统我们要使用这个接口。
  • device.controlTransferIn(): 发送控制传输请求,读取设备描述符。注意,这里的参数需要根据USB规范来设置,比如requestTyperecipientrequestvalueindexlength
  • device.transferOut(): 发送数据到设备的OUT端点。
  • device.transferIn(): 从设备的IN端点接收数据。
  • getDeviceDescriptor()getConfigurationDescriptor(): 解析设备描述符和配置描述符,将二进制数据转换为JavaScript对象。
  • getFeatureReport(): 获取HID设备的Feature Report。

3. USB设备准备:

要让WebUSB工作,你的USB设备需要支持WebUSB协议。简单来说,就是在设备的固件里添加一个WebUSB描述符,告诉浏览器这个设备支持WebUSB。 这个过程因设备而异,通常需要在设备固件中进行配置。你可以参考WebUSB官方文档和相关示例代码。

重要提示:

  • idVendoridProduct: 在 navigator.usb.requestDevice() 中,你可以使用 filters 选项来指定你想要连接的设备的 idVendoridProduct,这样可以避免弹出设备选择框,直接连接到指定的设备。
  • 错误处理: 代码中包含了简单的错误处理,但在实际应用中,你需要更完善的错误处理机制,以便更好地调试和处理异常情况。
  • 安全: WebUSB涉及到硬件访问,所以要特别注意安全问题。不要连接来历不明的USB设备,并仔细审查代码,防止恶意利用。
  • Endpoint 地址: transferIntransferOut 函数中的 endpoint 地址至关重要。 需要从 configuration descriptor 中解析出来,确保使用正确的 IN 和 OUT endpoint 地址。 地址的最高位表示方向(IN 或 OUT)。

4. 运行:

将HTML文件和JavaScript文件放在同一个目录下,用支持WebUSB的浏览器打开HTML文件,点击按钮,如果一切顺利,你就可以在浏览器控制台看到设备描述符的信息了。

四、进阶:自定义USB设备通信

有了设备描述符,你就可以根据设备的协议,发送和接收数据了。

通信步骤:

  1. 了解设备协议: 你需要了解你的USB设备使用的协议,比如是HID、CDC还是自定义协议。
  2. 查找端点: 通过解析配置描述符,找到用于数据传输的IN和OUT端点。
  3. 发送数据: 使用device.transferOut()发送数据到OUT端点。
  4. 接收数据: 使用device.transferIn()从IN端点接收数据。
  5. 解析数据: 根据设备协议,解析接收到的数据。

示例:

假设你的设备是一个简单的串口设备,使用CDC-ACM协议。你可以这样发送和接收数据:

// 发送数据
async function sendData(data) {
    await device.transferOut(OUT_ENDPOINT_ADDRESS, data); // OUT_ENDPOINT_ADDRESS 是你的OUT端点地址
}

// 接收数据
async function receiveData() {
    const result = await device.transferIn(IN_ENDPOINT_ADDRESS, 64); // IN_ENDPOINT_ADDRESS 是你的IN端点地址
    const receivedData = new Uint8Array(result.data.buffer);
    return receivedData;
}

// 使用示例
const dataToSend = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello"的ASCII码
await sendData(dataToSend);

const receivedData = await receiveData();
log('Received:', new TextDecoder().decode(receivedData)); // 将Uint8Array转换为字符串

五、总结

WebUSB为Web应用打开了与USB设备通信的大门。通过解析设备描述符,我们可以了解设备的特性,并根据设备的协议进行数据交互。虽然WebUSB还有一些限制,但随着技术的不断发展,它必将在Web开发中发挥越来越重要的作用。

希望今天的讲解能帮助你入门WebUSB,并开始探索USB设备通信的乐趣。 记住,实践是检验真理的唯一标准,多动手尝试,你才能真正掌握这项技术。 祝你玩得开心!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注