HID 设备对接技术解析:基于本地中间服务的 WebSocket 通信模式

1. 概述

在医疗临床试验 SaaS 系统中,前端需要与各类体征测量设备(血压计、体温计、身高体重秤等)进行实时数据通信。由于浏览器安全策略限制,JavaScript 无法直接通过 HID 协议操作 USB 设备,因此需要借助本地中间服务(Local Middleware Service)作为桥接层。

本文以 type2.js 为例,详细解析基于「HTTP + WebSocket」的 HID 设备对接方案的设计与实现。

相关文件:

  • src/components/DeviceSetting/type2.js --- 中间服务连接实现
  • src/components/DeviceSetting/type1.js --- 串口直连实现(Web Serial API)
  • src/components/DeviceSetting/parseDeviceData.js --- 设备数据解析引擎
  • src/components/DeviceSetting/index.vue --- 设备选择与连接管理 UI

2. 架构设计

2.1 整体架构

scss 复制代码
┌─────────────────────────────────────────────────────────────┐
│                       浏览器 (WebApp)                        │
│  ┌───────────────────────┐  ┌─────────────────────────────┐ │
│  │   type1.js (串口直连)  │  │  type2.js (中间服务模式)     │ │
│  │  Web Serial API       │  │  HTTP + WebSocket           │ │
│  └──────────┬────────────┘  └──────────────┬──────────────┘ │
│             │                               │               │
│      ┌──────┴──────┐                ┌───────┴───────┐      │
│      │  串口数据流   │                │  HTTP/WS 通信  │      │
│      └──────┬──────┘                └───────┬───────┘      │
└─────────────┼───────────────────────────────┼──────────────┘
              │                               │
    ┌─────────┴──────────┐       ┌────────────┴────────────┐
    │   USB 串口设备       │       │   本地中间服务 (Electron) │
    │   (串口直连)         │       │   HTTP :17001           │
    └────────────────────┘       │   WebSocket :17001/ws    │
                                 │   HID 协议操作 USB 设备   │
                                 └─────────────────────────┘

系统支持两种连接方式:

连接方式 适用场景 技术方案 浏览器要求
type1 串口设备 Web Serial API Chrome 89+
type2 HID 设备 / 跨平台 本地中间服务 + WebSocket 通用

2.2 选择策略

index.vue 中,根据 equipmentType 字段动态选择连接实现:

js 复制代码
// equipmentType 为 2 的设备使用中间服务方式,其余使用串口直连方式
const connectImpl = props.deviceType == 2 ? connectDeviceType2 : connectDeviceType1

3. type2.js 深度解析:中间服务连接模式

3.1 模块导出与初始化

js 复制代码
export default function connectDevice({ openSuccess, dataCallback, errorCallback }) {
  const app = getApp()
  let conecteStatus = ref(false) // 连接状态
  let deviceInfo = app.globalData._deviceInfo || null // 设备信息缓存
  let ws = null // WebSocket 实例
  let serverUrl = '' // 中间服务地址

  // 如果设备信息存在,尝试直接连接
  if (deviceInfo) {
    connect(deviceInfo)
  }
  // ...
}

设计要点:

  • 状态驱动conecteStatus 是响应式引用(ref),驱动 UI 层显示连接/断开状态
  • 缓存复用app.globalData._deviceInfo 缓存上次成功的设备配置,避免每次打开页面都重新选择设备
  • 回调注入:三个回调函数(openSuccess / dataCallback / errorCallback)由调用方注入,解耦连接逻辑与业务逻辑

3.2 设备配置解析

js 复制代码
function getMiddleConfig(deviceInfo) {
  const params = deviceInfo.equipmentParameters || {}
  const { serverUrl: url, ip, port, vendorId, vid, productId, pid, hidPath } = params

  // 中间服务地址构建
  let baseUrl = url || (ip && port ? `http://${ip}:${port}` : '') || 'http://localhost:17001'

  // 兼容未带协议前缀的地址
  if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
    baseUrl = 'http://' + baseUrl
  }

  // VID/PID 解析
  let vId = vendorId || vid
  let pId = productId || pid
  if (hidPath) {
    const [hvid, hpid] = String(hidPath).split('&')
    vId = hvid || vId
    pId = hpid || pId
  }

  return { serverUrl: baseUrl, vendorId: vId, productId: pId }
}

地址解析优先级:

配置来源 示例 优先级
serverUrl 完整地址 http://192.168.1.100:17001 最高
ip + port 组合 http://192.168.1.100:17001
本地默认地址 http://localhost:17001 最低(兜底)

HID 设备标识解析:

  • hidPath 字段格式为 "VID&PID"
  • 兼容字段 vendorId/vidproductId/pid 作为独立字段传入
  • 最终组合为 { vendorId, productId } 传递给中间服务用于 USB 设备识别

3.3 连接建立

js 复制代码
async function connect(options) {
  const { serverUrl: baseUrl, vendorId, productId } = getMiddleConfig(options)
  if (!baseUrl) {
    errorCallback('未配置中间服务地址')
    return
  }
  serverUrl = baseUrl

  try {
    const res = await fetch(`${baseUrl}/api/connect`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ vendorId, productId })
    })
    const d = await res.json()
    if (d.online) {
      conecteStatus.value = true
      app.globalData._deviceInfo = options
      openWs() // 建立 WebSocket 监听
      openSuccess(options) // 回调通知上层
    } else {
      errorCallback('设备连接失败')
    }
  } catch (e) {
    errorCallback(e) // 中间服务未启动等网络异常
  }
}

连接流程:

  1. 配置解析 → 获取中间服务地址和 HID 标识
  2. HTTP POST → 请求 /api/connect 通知中间服务连接 USB 设备
  3. 状态判断 → 中间服务返回 { online: true/false } 表示设备是否在线
  4. 成功处理 → 更新状态、缓存配置、建立 WebSocket 通道、回调通知
  5. 失败处理 → 错误回调,展示错误信息(中间服务未启动、设备未插拔等)

3.4 WebSocket 数据通道

js 复制代码
function openWs() {
  if (ws) {
    ws.close()
    ws = null
  }

  // HTTP → WebSocket 协议升级
  const wsUrl = serverUrl.replace(/^http(s)?:/, (_, s) => (s ? 'wss:' : 'ws:')) + '/ws'

  ws = new WebSocket(wsUrl)
  ws.onopen = () => {}
  ws.onclose = () => {
    conecteStatus.value = false
  }
  ws.onerror = () => {}
  ws.onmessage = (ev) => {
    dataCallback(
      parseDeviceData(
        app.globalData._deviceInfo?.equipmentParameters,
        ev.data,
        app.globalData._deviceInfo?.equipmentType
      )
    )
  }
}

WebSocket 协议升级:

bash 复制代码
http://localhost:17001  →  ws://localhost:17001/ws
https://192.168.1.100   →  wss://192.168.1.100/ws

数据通道设计模式:

通道 用途 方向 协议
HTTP 连接控制、设备初始化 浏览器 → 中间服务 POST /api/connect
WebSocket 实时数据推送 中间服务 → 浏览器 ws://host/ws

这种「HTTP 控制 + WebSocket 推送」的双通道模式,是物联网设备对接的经典模式:

  • HTTP 连接用于一次性控制指令(连接、断开、配置)
  • WebSocket 用于持续数据流(测量数据推送)
  • 协议分离,职责清晰,WebSocket 断开不影响 HTTP 重连

3.5 断开连接

js 复制代码
async function closeSerialPort(callback = function () {}) {
  const status = conecteStatus.value
  if (conecteStatus.value) {
    conecteStatus.value = false
    if (ws) {
      ws.close()
      ws = null
    }
    if (serverUrl) {
      try {
        // 发送空 VID/PID 通知中间服务断开设备
        await fetch(`${serverUrl}/api/connect`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ vendorId: 'FFFFFFFF', productId: 'FFFFFFFF' })
        })
      } catch (e) {
        console.log('断开连接失败', e)
      }
    }
  }
  callback(status)
}

断开策略:

  • FFFFFFFF 作为特殊的 VID/PID 标识,通知中间服务释放 USB 设备句柄
  • 先关闭 WebSocket,再发送 HTTP 断开请求,避免资源泄漏
  • 调用 callback 返回断开前的连接状态,供上层做状态清理

3.6 重连机制

js 复制代码
async function initDevice(options) {
  if (conecteStatus.value) {
    await closeSerialPort() // 先断开旧连接
  }
  await connect(options) // 重新连接
}

先断后连的「clean reconnect」模式,确保设备状态始终一致,避免重复连接导致资源冲突。

4. 设备数据解析引擎

parseDeviceData.js 提供两种解析模式:

4.1 正则解析(parsingMethod = 1)

js 复制代码
function parseByRegex(option, str) {
  const { fieldRules } = option || {}
  ;(fieldRules || []).forEach((item) => {
    const regexp = new RegExp(item.regexp)
    const res = str.match(regexp) || []
    let val = res[item.regexpIndex || 0]
    // 小数点处理、前缀零处理...
    obj[item.code] = { name: item.name, value: val }
  })
  return obj
}

适用于设备返回固定格式的字符串,如 "SYS:120, DIA:80, HR:75"

4.2 位置解析(parsingMethod = 2)

js 复制代码
function parseByPosition(option, str) {
  const { splitChar, fieldRules } = option || {}
  const arr = str.split(splitChar)
  const obj = {}
  fieldRules.forEach((item) => {
    let val = arr[item.regexpIndex]
    // 小数点处理、前缀零处理...
    obj[item.code] = { name: item.name, value: val }
  })
  return obj
}

适用于设备返回固定分隔符的字符串,如 "120,80,75"

4.3 数据解析配置示例

设备配置中的 equipmentParameters 字段包含解析规则:

json 复制代码
{
  "equipmentParameters": {
    "parsingMethod": 1,
    "splitChar": ",",
    "fieldRules": [
      { "code": "SYS", "name": "收缩压", "regexp": "SYS:(\\d+)", "regexpIndex": 1 },
      { "code": "DIA", "name": "舒张压", "regexp": "DIA:(\\d+)", "regexpIndex": 1 },
      { "code": "HR", "name": "心率", "regexp": "HR:(\\d+)", "regexpIndex": 1 }
    ],
    "decimalPlaces": 0,
    "prefixZero": false,
    "decimalZero": 2
  }
}

5. 与 type1(串口直连)的对比

对比维度 type1(串口直连) type2(中间服务模式)
底层 API Web Serial API HTTP + WebSocket
浏览器要求 Chrome 89+ 通用(所有浏览器)
设备兼容性 仅串口设备 HID / 串口 / 蓝牙
安装依赖 需安装本地中间服务
连接控制 navigator.serial.requestPort() POST /api/connect
数据接收 serialPort.readable.getReader() WebSocket.onmessage
数据粘包 需要防抖 + 超时机制 中间服务负责分包
跨平台 仅桌面 Chrome 全平台(依赖中间服务)
安全性 用户主动选择端口 需白名单配置

6. 中间服务技术要求

本地中间服务需实现:

端点 方法 功能 请求体
/api/connect POST 连接/断开 HID 设备 { vendorId, productId }
/ws WebSocket 推送设备测量数据 原始数据字符串

中间服务技术选型建议:

  • Electron + node-hid(跨平台 HID 操作)
  • Python + pywinusb/hidapi(Windows 环境)
  • C# + HidLibrary(Windows 环境)

7. 错误处理与异常场景

7.1 错误分类

错误类型 触发条件 处理方式
中间服务未启动 fetch 抛出网络异常 弹窗提示"请启动本地中间服务"
设备未连接 返回 { online: false } 提示"请插入设备后重试"
WebSocket 断开 onclose 触发 重置连接状态,显示"设备已断开"
设备类型不匹配 返回的 equipmentType 与预期不符 自动断开并提示"设备类型不匹配"

7.2 错误展示

vue 复制代码
<!-- 错误提示弹窗 -->
<ts-modal v-model:visible="showError" title="设备连接失败" width="400px" :footer="false">
  <ts-modal-content>
    <view style="font-weight: bold">请将下方的"错误提示"拍照发送给系统工程师:</view>
    <view style="background-color: #f5f5f5; padding: 10px; border-radius: 5px; margin-top: 10px">
      {{ errorMsg }}
    </view>
  </ts-modal-content>
</ts-modal>

8. 总结

type2.js 实现的「本地中间服务 + HTTP + WebSocket」模式,是一种成熟、可靠的 HID 设备对接方案:

  1. 跨浏览器兼容:不依赖 Web Serial / WebUSB 等新兴 API,所有浏览器均可使用
  2. 职责分离:浏览器负责 UI 和业务逻辑,中间服务负责硬件协议交互
  3. 数据流清晰:HTTP 控制连接生命周期,WebSocket 承载实时数据流
  4. 可扩展性:中间服务可对接任意类型的物理设备(HID、串口、蓝牙),对前端透明
  5. 错误隔离:设备连接异常不会影响主应用稳定性

这种模式适用于医疗临床试验、工业物联网、实验室设备对接等需要与 USB 外设通信的 Web 应用场景。

相关推荐
黄油面包13 分钟前
Codex 额度三天见底后,我重新做了一周预算
前端·人工智能
PedroQue9916 分钟前
v2.7.1:修复 H5 端返回死循环闪烁问题
前端·uni-app
coderCN18 分钟前
Nodejs express+knex(ORM框架)
前端·node.js
求道於盲22 分钟前
python中的抽象类
前端
Csvn32 分钟前
CSS 层叠与现代布局:BFC、@layer 与 grid/flex 的取舍
前端
CodeSheep41 分钟前
OpenJDK 全面禁止 AI 生成代码!
前端·后端·程序员
IT_陈寒1 小时前
为什么我的Vue组件总是莫名其妙重渲染?
前端·人工智能·后端
乘风gg1 小时前
企业级 AI Coding 的 Harness 工程实战:8 个 Skill 串起全链路
前端·ai编程·claude
染指11103 小时前
103.RAG-LLamaIndex后端rag问答-聊天接口
前端·javascript·vue.js·人工智能