JS `WebHID` `Report Descriptors` 解析与自定义设备通信协议

各位观众老爷,大家好!今天咱们来聊聊一个挺有意思的话题:JS WebHID Report Descriptors 解析与自定义设备通信协议。这玩意儿听起来有点高深,但其实没那么可怕。咱们用大白话,加上代码示例,把它给整明白。

一、WebHID:浏览器里的硬件握手专家

首先,什么是WebHID?简单来说,它是一个Web API,允许你在浏览器里直接和HID设备(Human Interface Devices,比如鼠标、键盘、游戏手柄,甚至是一些奇奇怪怪的自定义设备)进行通信。以前,这种事情只能通过安装Native App或者浏览器插件来完成,现在有了WebHID,妈妈再也不用担心我的浏览器被流氓软件污染了!

WebHID就像一个翻译官,它负责把浏览器里的JS代码翻译成HID设备能听懂的“暗号”,然后再把HID设备返回的信息翻译成JS代码能理解的数据。

二、Report Descriptors:HID设备的“户口本”

接下来,咱们说说Report Descriptors。你可以把它想象成HID设备的“户口本”,上面详细记录了设备的各种信息,比如:

  • 用途(Usage): 这家伙是干啥的?是鼠标?键盘?还是游戏手柄?
  • 用途页(Usage Page): 用途的分类,比如“通用桌面设备”、“游戏控制设备”等等。
  • 输入报告(Input Report): 设备向电脑报告数据的格式。
  • 输出报告(Output Report): 电脑向设备发送数据的格式。
  • 特征报告(Feature Report): 用于配置设备或者获取设备状态的报告。

Report Descriptors 是一个二进制的数据结构,按照一定的规范组织。想要和HID设备通信,第一步就是要读懂它的Report Descriptors,了解它的脾气秉性。

三、Report Descriptors 解析:解开“户口本”的密码

问题来了,Report Descriptors 是一堆二进制数据,人脑可读性几乎为零。这时候,就需要用到解析器了。目前,JavaScript 并没有内置的 Report Descriptor 解析器,所以我们需要自己撸一个,或者使用现成的库。这里我们自己实现一个简易的解析器,方便理解原理。

// 一个简易的 Report Descriptor 解析器
function parseReportDescriptor(descriptor) {
  let offset = 0;
  const items = [];

  while (offset < descriptor.length) {
    const prefix = descriptor[offset];
    offset++;

    const item = {};

    if ((prefix & 0b11111100) === 0b00001000) {
      // Short Item
      const size = prefix & 0b00000011;
      const type = (prefix >> 2) & 0b00000011;
      const tag = (prefix >> 4) & 0b00001111;

      item.type = getTypeString(type);
      item.tag = getTagString(tag);
      item.size = size;

      let value = 0;
      for (let i = 0; i < size; i++) {
        value |= descriptor[offset + i] << (i * 8);
      }
      item.value = value;
      offset += size;

    } else if ((prefix & 0b11111100) === 0b11001100) {
      // Long Item (不常用,这里省略解析)
      console.warn("Long Item encountered, skipping.");
      return null;
    } else {
      // Main Item
      const size = (prefix >> 2) & 0b00000011;
      const type = (prefix >> 2) & 0b00000011;
      const tag = (prefix >> 4) & 0b00001111;

      item.type = getTypeString(type);
      item.tag = getMainTagString(tag);
      item.size = size;

      items.push(item);
    }
    items.push(item);
  }

  return items;

  function getTypeString(type) {
    switch (type) {
      case 0: return "Main";
      case 1: return "Global";
      case 2: return "Local";
      default: return "Reserved";
    }
  }

  function getTagString(tag) {
    switch (tag) {
      case 0x00: return "Usage Page";
      case 0x01: return "Logical Minimum";
      case 0x02: return "Logical Maximum";
      case 0x03: return "Physical Minimum";
      case 0x04: return "Physical Maximum";
      case 0x05: return "Unit Exponent";
      case 0x06: return "Unit";
      case 0x07: return "Report Size";
      case 0x08: return "Report ID";
      case 0x09: return "Report Count";
      case 0x0A: return "Push";
      case 0x0B: return "Pop";
      default: return "Reserved";
    }
  }

  function getMainTagString(tag) {
    switch (tag) {
      case 0x08: return "Input";
      case 0x09: return "Output";
      case 0x0B: return "Feature";
      case 0x0A: return "Collection";
      case 0x0C: return "End Collection";
      default: return "Reserved";
    }
  }
}

// 示例:假设我们有一个 Report Descriptor (简化版)
const reportDescriptor = new Uint8Array([
  0x05, 0x01,  // Usage Page (Generic Desktop)
  0x09, 0x02,  // Usage (Mouse)
  0xA1, 0x01,  // Collection (Application)
  0x09, 0x01,  //   Usage (Pointer)
  0xA1, 0x00,  //   Collection (Physical)
  0x05, 0x09,  //     Usage Page (Buttons)
  0x19, 0x01,  //     Usage Minimum (Button 1)
  0x29, 0x03,  //     Usage Maximum (Button 3)
  0x15, 0x00,  //     Logical Minimum (0)
  0x25, 0x01,  //     Logical Maximum (1)
  0x95, 0x03,  //     Report Count (3)
  0x75, 0x01,  //     Report Size (1)
  0x81, 0x02,  //     Input (Data, Variable, Absolute)
  0x95, 0x01,  //     Report Count (1)
  0x75, 0x05,  //     Report Size (5)
  0x81, 0x03,  //     Input (Constant, Variable, Absolute) - Padding
  0x05, 0x01,  //     Usage Page (Generic Desktop)
  0x09, 0x30,  //     Usage (X)
  0x09, 0x31,  //     Usage (Y)
  0x15, 0x81,  //     Logical Minimum (-127)
  0x25, 0x7F,  //     Logical Maximum (127)
  0x75, 0x08,  //     Report Size (8)
  0x95, 0x02,  //     Report Count (2)
  0x81, 0x06,  //     Input (Data, Variable, Relative)
  0xC0,        //   End Collection
  0xC0         // End Collection
]);

const parsedDescriptor = parseReportDescriptor(reportDescriptor);
console.log(parsedDescriptor);

这个简易的解析器只能处理一部分的 Report Descriptor Item,但是足以说明原理了。完整的解析器需要处理 Short Item, Long Item, Main Item 三种类型,以及各种不同的 Tag。如果你不想自己造轮子,也可以使用现成的库,比如 node-hid

解析 Report Descriptor 之后,我们就可以知道设备有哪些输入报告、输出报告,以及每个报告的格式。

四、自定义设备通信协议:我和设备有个约会

有了 Report Descriptors,我们就能知道HID设备的“户口”,接下来就要和设备“搭讪”了,也就是定义通信协议。

自定义通信协议,说白了就是定义一套规则,告诉设备:

  • 我(浏览器)要发送什么样的数据给你?
  • 你(设备)要以什么样的格式回复我?

这个协议可以非常简单,也可以非常复杂,取决于你的设备的功能和需求。

1. 选择报告类型(Report ID)

首先,你需要选择使用哪种报告类型进行通信。通常,我们会使用:

  • 输入报告(Input Report): 用于设备向电脑发送数据。
  • 输出报告(Output Report): 用于电脑向设备发送数据。
  • 特征报告(Feature Report): 用于配置设备或获取设备状态。

每个报告类型都有一个唯一的 Report ID。如果设备支持多个报告类型,你需要明确指定使用哪个 Report ID。有些设备可能没有 Report ID,这时候 Report ID 默认为 0。

2. 定义数据格式

接下来,你需要定义数据的格式。这包括:

  • 数据类型: 比如整数、浮点数、字符串等等。
  • 数据长度: 每个数据占用的字节数。
  • 数据顺序: 数据的排列顺序(比如大端序、小端序)。
  • 数据含义: 每个数据代表什么意思。

例如,假设我们要控制一个LED灯的亮度,我们可以定义一个简单的输出报告:

字节 含义 数据类型 范围
0 Report ID 整数 1 (固定)
1 亮度值 整数 0-255

这个报告的 Report ID 是 1,第一个字节是 Report ID,第二个字节是亮度值,范围是 0-255。

3. 代码实现

有了协议,就可以用代码来实现通信了。

// 获取 HID 设备
async function getHIDDevice() {
  const devices = await navigator.hid.requestDevice({
    filters: [{ vendorId: 0x1234, productId: 0x5678 }], // 替换为你的设备的 Vendor ID 和 Product ID
  });
  if (devices.length > 0) {
    const device = devices[0];
    await device.open();
    return device;
  } else {
    return null;
  }
}

// 发送数据到设备
async function sendData(device, brightness) {
  const reportId = 1;
  const data = new Uint8Array([reportId, brightness]);
  await device.sendReport(reportId, data);
}

// 接收来自设备的数据
async function receiveData(device) {
  device.addEventListener("inputreport", (event) => {
    const { data, reportId } = event;
    const value = data.getUint8(0); // 读取第一个字节的数据
    console.log(`Received report ${reportId} with value ${value}`);
  });
}

// 主函数
async function main() {
  const device = await getHIDDevice();
  if (device) {
    console.log(`Connected to device: ${device.productName}`);
    receiveData(device); // 开始监听设备发来的数据

    // 示例:设置 LED 亮度为 128
    await sendData(device, 128);
    console.log("Sent brightness command.");
  } else {
    console.log("No HID device selected.");
  }
}

main();

这段代码首先获取HID设备,然后定义了 sendData 函数来发送数据到设备,以及 receiveData 函数来接收来自设备的数据。

五、踩坑指南:WebHID 的那些坑

WebHID 虽然强大,但也不是万能的。在使用过程中,你可能会遇到一些坑:

  • 权限问题: 浏览器需要用户授权才能访问HID设备。
  • 兼容性问题: 不同的浏览器对WebHID的支持程度可能不同。
  • 数据格式问题: 确保你发送和接收的数据格式与设备的期望一致。
  • 设备断开问题: 监听 disconnect 事件,及时处理设备断开的情况。
  • Vendor ID 和 Product ID: 确保你的 Vendor ID 和 Product ID 是正确的,否则浏览器无法找到你的设备。

六、总结:WebHID 的未来

WebHID 为Web应用打开了一扇通往硬件世界的大门。虽然目前还存在一些限制和挑战,但随着Web技术的不断发展,WebHID 的应用前景将越来越广阔。

希望通过今天的讲解,大家对 WebHID 有了更深入的了解。下次再遇到HID设备,就可以自信地说:“WebHID,安排!”

好了,今天的讲座就到这里,谢谢大家! 散会, 散会!

发表回复

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