commit 7af53efaa964813d498c4974687ed9bb52f8214f Author: jiangchen <1703935164@qq.com> Date: Wed Sep 23 00:35:13 2026 +0800 feat: 浏览器端 CH340/CH341 WebUSB 驱动 + navigator.serial 垫片 - ch340-webusb.js: WebUSB 上完整的 USB-Serial 栈 · CH340/CH341 vendor 控制传输初始化(对齐 Linux ch341.c) · 波特率分频算法 calcCh34xBaud(1532620800 基准) · bulk IN 32 深读队列 + ReadableStream / 4096 分片写 + WritableStream · DTR/RTS(0xA4 取反写入)、拔插事件、诊断计数 · 附带 CDC-ACM(CH343/CH9102/CH347)、CP210x、FTDI(含 modem 字节剥离)、PL2303(HXN/HX/01/T) - ch340-serial-shim.js: 安卓上补出 navigator.serial,老代码零改动可用 - ch340-test.html: 连接/收发/HEX/DTR-RTS/回环自测 测试台 - docs/ch34x-registers.md: 寄存器与控制传输逆向说明 - test/driver.test.mjs: 15 项 mock 断言(node,无需硬件) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb45af1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jiangchen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..68046a8 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# ch340-webusb + +浏览器直连 **CH340 / CH341**(以及 CDC-ACM、CP210x、FTDI、PL2303)USB 转串口芯片的纯前端驱动。 + +- **不用装 App、不用 root、不用装驱动**:安卓 Chrome/Edge 走 **WebUSB**,桌面 Chrome/Edge 走 **Web Serial**。 +- **安卓浏览器终于能连 CH340 了**:安卓版 Chrome 有 WebUSB,但至今没有 Web Serial;本项目在 JS 里补上了 Web Serial 缺失的"芯片驱动层"(波特率寄存器、流控、LCR、DTR/RTS),并提供一层 `navigator.serial` 垫片,**让已有的串口业务代码零改动跑在安卓浏览器里**。 + +``` +┌──────────────┐ navigator.usb (WebUSB) ┌──────────────┐ +│ 你的网页 │ ─────────────────────────▶ │ CH340 芯片 │──── UART ────▶ 设备 +│ ch340-webusb │ vendor 控制传输 + bulk │ (无需驱动) │ +└──────────────┘ └──────────────┘ +``` + +## 快速开始 + +```html + + +``` + +### 方式一:直接用(WebUSB,安卓 + 桌面通用) + +```js +const port = await TamaCh340.requestPort({ baudRate: 460800 }); // 弹系统选设备框 +await port.open({ baudRate: 460800 }); // 内部完成 CH340 初始化 +const reader = port.readable.getReader(); +const writer = port.writable.getWriter(); + +await writer.write(new TextEncoder().encode('hello\r\n')); +const { value } = await reader.read(); // Uint8Array +await port.close(); +``` + +### 方式二:当它是 Web Serial(零改动接入老站) + +垫片会在**安卓**上(有 WebUSB、没有 Web Serial 时)自动补出 `navigator.serial`, +之后原来写给 Web Serial 的代码/打包好的 SPA 直接就能用: + +```js +const port = await navigator.serial.requestPort(); // 安卓上返回的是 WebUSB 的 CH340 +await port.open({ baudRate: 460800 }); +``` + +> 桌面浏览器本来就有真 Web Serial,垫片**不会**安装,避免改变原有行为。 + +### 方式三:测试台 + +打开 `ch340-test.html`(需要 HTTPS 或 localhost):连接、收发、HEX、DTR/RTS、回环自测、诊断计数一屏搞定。 + +```bash +npx serve . # 或 python -m http.server 8080 +# 手机访问 https 地址;安卓上建议用 cloudflared/ngrok 暴露本地端口 +``` + +## API + +| 成员 | 说明 | +|---|---| +| `TamaCh340.requestPort(opts)` | 弹设备选择框,返回**未打开**的端口对象 | +| `TamaCh340.connect(opts)` | 选择 + 打开一步到位 | +| `TamaCh340.fromDevice(device, opts)` | 用已授权的 `USBDevice` 建端口 | +| `TamaCh340.getAuthorizedDevices()` | 之前授权过的设备(拔插后可免弹窗重连) | +| `TamaCh340.chipName(vid, pid)` | `'ch340' / 'cp210x' / 'ftdi' / 'pl2303' / ...` | +| `TamaCh340.calcCh34xBaud(baud)` | `{factor, divisor}`,与 Linux `ch341.c` 一致 | +| `TamaCh340.onLog = fn` / `debug = true` | 日志钩子 / 控制台输出 | + +端口对象(与 Web Serial 的 `SerialPort` 对齐): +`open({baudRate,dataBits,stopBits,parity,flowControl,readTransferSize,readQueueDepth})` · +`close()` · `getInfo()` · `readable` · `writable` · `read(ms)` · `write(bytes)` · +`setSignals({dataTerminalReady,requestToSend})` · `forget()` · `addEventListener('disconnect', fn)` · +`getDiagnostics()` · `device`(原始 `USBDevice`)· `chip` + +## 支持的芯片 + +| 驱动 | VID:PID | 说明 | +|---|---|---| +| **CH340/CH341** | `1a86:7523`, `1a86:5523`(+ `5512/7522` 克隆) | 主目标;原厂拓麻歌子 USB 线就是 CH340C | +| CDC-ACM | `1a86:55d2…55e7` 等 | CH343 / CH9102 / CH347 的 CDC 模式 | +| CP210x | `10c4:ea60/ea70/ea71` | Silicon Labs | +| FTDI | `0403:6001/6010/6011/6014/6015` | 含 modem 状态字节剥离 | +| PL2303 | `067b:2303…23f3` | 含 HXN/HX/01/T 变种识别 | + +## 各平台要不要装驱动? + +| 平台 | 浏览器通道 | 需要额外驱动吗 | +|---|---|---| +| **安卓**(Chrome/Edge/Brave/Kiwi) | WebUSB | **完全不用**。CH340 是 vendor-specific 类,内核不绑定,浏览器直接抢占接口 | +| **Linux** | Web Serial(桌面首选) | 不用,内核自带 `ch341` | +| **macOS** | Web Serial(桌面首选) | 不用,系统自带 Apple USB 串口驱动 | +| **Windows** | Web Serial(桌面首选) | 装一次 WCH 官方 CH341SER 即可;**WebUSB 反而需要把设备换成 WinUSB 驱动(Zadig)才能用**——所以桌面端本库优先走 Web Serial | + +也就是说:**安卓端零驱动**(这正是本项目的重点),桌面端用系统/官方驱动 + Web Serial 即可。 +垫片只在"有 WebUSB 且没有 Web Serial"时安装,桌面端不会被改变行为。 + +## 安卓注意事项 + +1. **必须 HTTPS**(`localhost` 除外),且要用**用户手势**(点击)触发连接。 +2. 只有 **Chromium 系**浏览器可以:Chrome / Edge / Brave / Kiwi。Firefox 安卓没有 WebUSB,iOS 全线没有。 +3. 需要 **OTG**:手机要支持 USB Host;部分机型 OTG 与充电互斥、供电不足会表现为反复掉线。 +4. **CH340 是 vendor-specific 类(0xFF)**,安卓内核不会去绑定它,所以能被浏览器抢占; + CDC-ACM 类设备在个别 ROM 上会被内核 `cdc_acm` 先占,`claimInterface` 会失败(报错已给出提示)。 +5. 连接后页面切后台 / 锁屏会被节流断流,长任务建议申请 Wake Lock: + `await navigator.wakeLock.request('screen')`。 +6. 拔插后 `getDevices()` 能拿回已授权设备,可直接重连,不必再弹窗。 + +## 实现要点(为什么必须自己写驱动) + +浏览器的 Web Serial 在桌面端内置了芯片驱动,而 WebUSB 只给"裸 USB"。 +所以本驱动用控制传输复刻了内核里的那套寄存器操作,CH340 的初始化序列: + +``` +0x5F 读版本自检 +0xA1 0000 0000 清 modem 控制 +0x9A 1312 波特率(先按 9600 配置) +0x9A 0F2C +0x95 2518 (读 LCR) 0x9A 2518 00C3 → 8 数据位 / 1 停止位 / 无校验 +0x95 0706 (读) +0xA1 501F D90A 关硬件流控 +0x9A 1312/0F2C <目标波特率> +0x9A 2518 00C3 +0xA4 FFFF DTR/RTS 复位 +0xA4 FF9F DTR+RTS 有效(位取反写入) +``` + +波特率分频:`factor = floor(1532620800 / baud)`,>65520 则右移 3 位并叠加预分频, +再取 `0x10000 - factor`,高位写 `0x1312`、低位写 `0x0F2C`。 +详见 `docs/ch34x-registers.md`。 + +## 测试 + +```bash +node test/driver.test.mjs +``` + +14 项断言全部离线跑(mock `USBDevice`):波特率算法、完整控制传输序列、8N1 校验、 +4096 分片写、ReadableStream 读、DTR/RTS、关闭清理、CDC 布局与 SET_LINE_CODING、 +FTDI modem 字节剥离、垫片自动安装。 + +## 许可 + +MIT diff --git a/ch340-serial-shim.js b/ch340-serial-shim.js new file mode 100644 index 0000000..e24ced7 --- /dev/null +++ b/ch340-serial-shim.js @@ -0,0 +1,174 @@ +/*! + * ch340-serial-shim.js — 让 WebUSB 的 CH340 变成 navigator.serial + * ------------------------------------------------------------------ + * 桌面 Chrome/Edge 本来就有 Web Serial,一行 port.open({baudRate}) 就能用 CH340。 + * 安卓 Chrome 有 WebUSB 但没有 Web Serial,于是所有 "Connect USB" 都会显示不支持。 + * 本垫片在安卓上补出 navigator.serial:requestPort() 打开的是 WebUSB 的 CH340, + * 返回的对象与 SerialPort 语义一致(open/close/getInfo/readable/writable/setSignals), + * 于是已有串口业务代码(包括打包好的 SPA)零改动即可在安卓浏览器里跑。 + * + * 加载顺序(重要): + * + * + * + * + * + * 可选配置:加载前设置 window.__CH340_SHIM + * { autoInstall:true, baudRate:460800, allDrivers:false, force:false, prefer:'usb' } + * window.__CH340_PREFER = 'bluetooth' → 在 chain 模式下让位给原有垫片(例如 BLE) + * 许可:MIT + */ +(function () { + 'use strict'; + + var g = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : this); + var api = g.TamaCh340; + var state = { installed: false, reason: null, previous: null }; + var listeners = { connect: [], disconnect: [] }; + + function merge(a, b) { + var out = {}, k; + for (k in (a || {})) if (Object.prototype.hasOwnProperty.call(a, k)) out[k] = a[k]; + for (k in (b || {})) if (Object.prototype.hasOwnProperty.call(b, k)) out[k] = b[k]; + return out; + } + + /** Web Serial 的 filters([{usbVendorId,usbProductId}]) → WebUSB filters */ + function toUsbFilters(serialOptions, fallbackOptions) { + var f = serialOptions && serialOptions.filters; + if (Array.isArray(f) && f.length) { + var mapped = f + .filter(function (x) { return x && x.usbVendorId !== undefined; }) + .map(function (x) { return { vendorId: x.usbVendorId, productId: x.usbProductId }; }); + if (mapped.length) return mapped; + } + if (fallbackOptions && fallbackOptions.filters) return fallbackOptions.filters; + if (fallbackOptions && fallbackOptions.allDrivers) return api.supportedFilters(); + return api.ch34xFilters(); + } + + function emit(type, port) { + var ev = { type: type, port: port, target: null }; + var arr = listeners[type] || []; + for (var i = 0; i < arr.length; i++) { try { arr[i](ev); } catch (e) {} } + var serial = g.navigator && g.navigator.serial; + var handler = serial && (type === 'connect' ? serial.onconnect : serial.ondisconnect); + if (typeof handler === 'function') { try { handler(ev); } catch (e) {} } + } + + /** 包装端口:补齐 Web Serial 的细节语义 */ + function wrap(port) { + if (port.__ch340Wrapped) return port; + port.__ch340Wrapped = true; + port._signals = { dataTerminalReady: false, requestToSend: false }; + var rawSet = port.setSignals.bind(port); + port.setSignals = async function (sig) { + sig = sig || {}; + await rawSet(sig); + if (sig.dataTerminalReady !== undefined) port._signals.dataTerminalReady = !!sig.dataTerminalReady; + if (sig.requestToSend !== undefined) port._signals.requestToSend = !!sig.requestToSend; + }; + port.getSignals = async function () { + return { + dataCarrierDetect: false, clearToSend: false, ringIndicator: false, + dataSetReady: !!port.opened, + dataTerminalReady: port._signals.dataTerminalReady, + requestToSend: port._signals.requestToSend + }; + }; + return port; + } + + /** + * 安装垫片 + * @returns {{ok:boolean, reason?:string}} + */ + function install(options) { + options = options || {}; + var nav = g.navigator; + if (!nav) { state.reason = '没有 navigator'; return { ok: false, reason: state.reason }; } + if (!api) { state.reason = '请先加载 ch340-webusb.js'; return { ok: false, reason: state.reason }; } + if (!nav.usb) { state.reason = '这个浏览器没有 WebUSB(iOS 不支持)'; return { ok: false, reason: state.reason }; } + if (nav.serial && nav.serial.__ch340Shim) { + state.installed = true; state.reason = null; return { ok: true, reason: 'already' }; + } + if (nav.serial && !options.force) { + state.reason = 'navigator.serial 已存在(桌面浏览器或其它垫片先装了);需要接管请传 {force:true}'; + return { ok: false, reason: state.reason }; + } + + var previous = nav.serial || null; + state.previous = previous; + + async function usbRequestPort(serialOptions) { + var filters = toUsbFilters(serialOptions, options); + var device = await nav.usb.requestDevice({ filters: filters }); + return wrap(api.fromDevice(device, merge(options, serialOptions))); + } + + nav.serial = { + __ch340Shim: true, + __previous: previous, + requestPort: async function (serialOptions) { + var prefer = g.__CH340_PREFER || options.prefer || 'usb'; + if (prefer === 'bluetooth' && previous && previous.requestPort) { + return previous.requestPort(serialOptions); + } + try { + return await usbRequestPort(serialOptions); + } catch (e) { + // 用户取消 USB 选择框时,可选回退到原有垫片(例如 BLE) + if (e && e.name === 'NotFoundError' && options.fallbackOnCancel && previous && previous.requestPort) { + return previous.requestPort(serialOptions); + } + throw e; + } + }, + getPorts: async function () { + var devices = await api.getAuthorizedDevices(options); + return devices.map(function (d) { return wrap(api.fromDevice(d, options)); }); + }, + addEventListener: function (type, fn) { if (listeners[type]) listeners[type].push(fn); }, + removeEventListener: function (type, fn) { + if (!listeners[type]) return; + listeners[type] = listeners[type].filter(function (f) { return f !== fn; }); + }, + onconnect: null, + ondisconnect: null + }; + + // 拔插事件透传(业务代码常靠它做重连) + try { + nav.usb.addEventListener('connect', function (ev) { + var d = ev && ev.device; + if (!d) return; + try { emit('connect', wrap(api.fromDevice(d, options))); } catch (e) {} + }); + nav.usb.addEventListener('disconnect', function (ev) { + var d = ev && ev.device; + if (!d) { emit('disconnect', null); return; } + try { emit('disconnect', wrap(api.fromDevice(d, options))); } catch (e) { emit('disconnect', null); } + }); + } catch (e) { /* 个别实现没有 addEventListener,忽略 */ } + + state.installed = true; + state.reason = null; + if (api.debug && typeof console !== 'undefined') console.log('[ch340] navigator.serial 垫片已安装'); + return { ok: true }; + } + + g.TamaSerialShim = { + install: install, + get installed() { return state.installed; }, + get reason() { return state.reason; }, + get previous() { return state.previous; }, + version: api ? api.version : null + }; + + // ── 自动安装:有 WebUSB 且没有 navigator.serial 时装上(安卓默认走这条;桌面不动) ── + var cfg = merge({ autoInstall: true, baudRate: 460800 }, g.__CH340_SHIM); + if (cfg.autoInstall && g.navigator && g.navigator.usb && !g.navigator.serial && api) { + var res = install(cfg); + if (api.debug && typeof console !== 'undefined') console.log('[ch340] 自动安装: ' + JSON.stringify(res)); + } +})(); diff --git a/ch340-test.html b/ch340-test.html new file mode 100644 index 0000000..f167441 --- /dev/null +++ b/ch340-test.html @@ -0,0 +1,265 @@ + + + + + +CH340 WebUSB 测试台 · ch340-webusb + + + +CH340 WebUSB 测试台 +ch340-webusb · 浏览器直连 USB 串口芯片(安卓 WebUSB / 桌面 Web Serial) + + + + WebUSB ? + Web Serial ? + 平台 ? + 垫片 ? + + + 连接设备 + 断开 + 忘记授权 + + + 波特率 + + 9600192003840057600 + 115200230400460800921600 + + + DTR + RTS + 调试日志 + + + 芯片— + VID/PID— + 端点— + 收/发0 B / 0 B + + + + + + + HEX + 加 \r\n + + + 发送 + 回环自测(TX-RX 短接) + 清空日志 + 日志用 HEX + + + + + + + 安卓:Chrome/Edge + OTG 线,页面必须 HTTPS;首次连接会弹两次确认(Chrome 选择设备 + 安卓 USB 权限)。 + 桌面:Chrome/Edge 走 Web Serial,本页 连接设备 会优先用原生串口,失败再回退 WebUSB。 + 看不到设备?确认线是数据线(不是纯充电线)、玩具已开机、且没被别的 App 占用。 + + + + + + + + diff --git a/ch340-webusb.js b/ch340-webusb.js new file mode 100644 index 0000000..cc47eb0 --- /dev/null +++ b/ch340-webusb.js @@ -0,0 +1,752 @@ +/*! + * ch340-webusb.js — 浏览器直连 USB-Serial 芯片(CH340 为主,兼容 CH341/CDC-ACM/CP210x/FTDI/PL2303) + * --------------------------------------------------------------------------------------------- + * 不用装 App、不用 root、不用驱动:安卓 Chrome 走 WebUSB,桌面 Chrome/Edge 走 Web Serial。 + * 浏览器本身没有"串口"语义,波特率/流控这些寄存器配置由本文件用控制传输(vendor request)完成。 + * + * CH340 初始化序列对齐 Linux 内核 drivers/usb/serial/ch341.c 与 bbamorachi 站点实现: + * 0x5F 读版本 → 0xA1 清 modem → 0x9A 写 0x1312/0x0F2C 设波特率 → 0x95 读 0x2518 LCR + * → 0x9A 写 0x2518=0xC3(8N1) → 0x95 读 0x0706 → 0xA1 0x501F/0xD90A 关流控 + * → 0x9A 再设波特率 → 0xA4 0xFFFF(复位 DTR/RTS) → 0x9A 0x2518=0xC3 → 0xA4 0xFF9F(DTR+RTS 有效) + * + * 用法(WebUSB,安卓/桌面通用): + * const port = await TamaCh340.requestPort({ baudRate: 460800 }); + * await port.open({ baudRate: 460800 }); // 内部完成芯片初始化 + * const reader = port.readable.getReader(); // 和 Web Serial 的用法完全一样 + * const writer = port.writable.getWriter(); + * await writer.write(new Uint8Array([1,2,3])); + * + * 许可:MIT + */ +(function (root, factory) { + var api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.TamaCh340 = api; +})(typeof globalThis !== 'undefined' ? globalThis : (typeof self !== 'undefined' ? self : this), function () { + 'use strict'; + + var VERSION = '1.0.0'; + var DEFAULT_BAUD = 460800; // 拓麻歌子原厂线/UART 速率;其它场景请按需传参 + var WRITE_CHUNK = 4096; // bulk OUT 分片大小 + + // ───────────────────────────── 基础工具 ───────────────────────────── + var api = { version: VERSION, onLog: null, debug: false }; + + function log() { + if (api.onLog) { try { api.onLog.apply(null, arguments); } catch (e) { /* 用户回调异常不能影响驱动 */ } } + if (api.debug && typeof console !== 'undefined') { + var a = Array.prototype.slice.call(arguments); + a.unshift('[ch340]'); console.log.apply(console, a); + } + } + + /** 统一转成独立 Uint8Array(DataView/TypedArray 都能吃,且避免 buffer 被 detach) */ + function toU8(data) { + if (data instanceof Uint8Array) return new Uint8Array(data); + if (data && data.buffer) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); + return new Uint8Array(data || 0); + } + + function hasWebUSB() { return typeof navigator !== 'undefined' && !!navigator.usb; } + function hasWebSerial() { return typeof navigator !== 'undefined' && !!navigator.serial; } + function isAndroid() { return typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent || ''); } + + // ───────────────────────────── 布局解析(端点发现) ───────────────────────────── + /** 通用 vendor 串口:第一个同时带 bulk IN/OUT 的接口(CH340 就是接口 0) */ + function genericLayout(configuration) { + var ifaces = configuration.interfaces; + for (var i = 0; i < ifaces.length; i++) { + var iface = ifaces[i]; + for (var j = 0; j < iface.alternates.length; j++) { + var alt = iface.alternates[j]; + var epIn = null, epOut = null; + for (var k = 0; k < alt.endpoints.length; k++) { + var ep = alt.endpoints[k]; + if (ep.type === 'bulk' && ep.direction === 'in' && !epIn) epIn = ep; + if (ep.type === 'bulk' && ep.direction === 'out' && !epOut) epOut = ep; + } + if (epIn && epOut) { + return { + claimedInterfaces: [iface.interfaceNumber], + interfaceAlternates: [{ interfaceNumber: iface.interfaceNumber, alternateSetting: alt.alternateSetting }], + controlInterface: iface.interfaceNumber, + input: epIn, + output: epOut + }; + } + } + } + throw new Error('这个 USB 设备没有可用的 bulk 输入/输出端点(不是串口芯片?)'); + } + + function findInterfaceByClass(configuration, classCode) { + var ifaces = configuration.interfaces; + for (var i = 0; i < ifaces.length; i++) { + for (var j = 0; j < ifaces[i].alternates.length; j++) { + if (ifaces[i].alternates[j].interfaceClass === classCode) { + return { usbInterface: ifaces[i], alternate: ifaces[i].alternates[j] }; + } + } + } + return null; + } + + /** CDC-ACM:控制接口 class 2 + 数据接口 class 10(CH343/CH9102/CH347 的 CDC 模式等) */ + function cdcLayout(configuration) { + var comm = findInterfaceByClass(configuration, 0x02); + var data = findInterfaceByClass(configuration, 0x0a); + if (!comm || !data) throw new Error('CDC-ACM 控制/数据接口不完整'); + var epIn = null, epOut = null; + for (var k = 0; k < data.alternate.endpoints.length; k++) { + var ep = data.alternate.endpoints[k]; + if (ep.type === 'bulk' && ep.direction === 'in' && !epIn) epIn = ep; + if (ep.type === 'bulk' && ep.direction === 'out' && !epOut) epOut = ep; + } + if (!epIn || !epOut) throw new Error('CDC-ACM 数据端点不可用'); + return { + claimedInterfaces: [comm.usbInterface.interfaceNumber, data.usbInterface.interfaceNumber], + interfaceAlternates: [ + { interfaceNumber: comm.usbInterface.interfaceNumber, alternateSetting: comm.alternate.alternateSetting }, + { interfaceNumber: data.usbInterface.interfaceNumber, alternateSetting: data.alternate.alternateSetting } + ], + controlInterface: comm.usbInterface.interfaceNumber, + input: epIn, + output: epOut + }; + } + + // ───────────────────────────── 控制传输上下文 ───────────────────────────── + function ControlContext(device, layout) { this.device = device; this.layout = layout; } + + ControlContext.prototype.controlIn = function (requestType, recipient, request, value, index, length) { + var self = this; + return this.device.controlTransferIn({ + requestType: requestType, recipient: recipient, request: request, value: value, index: index + }, length).then(function (r) { + if (r.status !== 'ok' || !r.data || r.data.byteLength !== length) { + throw new Error('USB 控制读 0x' + request.toString(16) + ' 失败 (' + r.status + ')'); + } + return new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength).slice(); + }); + }; + + ControlContext.prototype.controlOut = function (requestType, recipient, request, value, index, data) { + var payload = data ? toU8(data) : undefined; + return this.device.controlTransferOut({ + requestType: requestType, recipient: recipient, request: request, value: value, index: index + }, payload).then(function (r) { + if (r.status !== 'ok' || (payload && r.bytesWritten !== undefined && r.bytesWritten !== payload.byteLength)) { + throw new Error('USB 控制写 0x' + request.toString(16) + ' 失败 (' + r.status + ')'); + } + return r; + }); + }; + + // 便捷包装:vendor 请求 + function vOut(ctx) { return function (req, val, idx, data) { return ctx.controlOut('vendor', 'device', req, val, idx, data); }; } + function vIn(ctx) { return function (req, val, idx, len) { return ctx.controlIn('vendor', 'device', req, val, idx, len); }; } + + // ───────────────────────────── CH340 / CH341 ───────────────────────────── + var CH34X_VID = 0x1a86; + var CH34X_PIDS = [0x7523, 0x5523]; // CH340 / CH341(最常见的两种) + // 少数 CH341A 克隆/变种会报这两个 PID;默认也接受,如遇异常可注释掉 + var CH34X_EXTRA_PIDS = [0x5512, 0x7522]; + + /** CH34x 波特率 → { factor, divisor },算法与 Linux ch341.c 完全一致 */ + function calcCh34xBaud(baud) { + if (!Number.isInteger(baud) || baud <= 0) throw new Error('波特率必须是正整数'); + var factor = Math.floor(1532620800 / baud); + var ps = 3; + while (factor > 65520 && ps > 0) { factor >>= 3; ps -= 1; } + if (factor > 65520) throw new Error('CH34x 不支持该波特率: ' + baud); + factor = 65536 - factor; + ps |= 0x80; + return { factor: (factor & 0xff00) | ps, divisor: factor & 0xff }; + } + + var ch34x = { + name: 'CH340/CH341', + readQueueDepth: 32, // 预排队 32 个 bulk 读,避免安卓上丢数据 + filters: [{ vendorId: CH34X_VID, productId: 0x7523 }, { vendorId: CH34X_VID, productId: 0x5523 }], + supports: function (d) { return d.vendorId === CH34X_VID && CH34X_PIDS.indexOf(d.productId) !== -1; }, + layout: genericLayout, + initialize: async function (ctx, opts) { + var out = vOut(ctx); + var read = vIn(ctx); + var setBaud = async function (baud) { + var d = calcCh34xBaud(baud); + await out(0x9a, 0x1312, d.factor); // 波特率高位/分频 + await out(0x9a, 0x0f2c, d.divisor); // 波特率低位 + }; + // 1. 版本自检:必须返回 [?, 0x00] + var ver = await read(0x5f, 0x0000, 0x0000, 2); + if (ver[1] !== 0) throw new Error('CH34x 版本应答异常: 0x' + ver[0].toString(16) + ' 0x' + ver[1].toString(16)); + // 2. 关 modem 控制 → 先按 9600 配置(芯片上电默认值,所有初始化都从这里开始) + await out(0xa1, 0x0000, 0x0000); + await setBaud(9600); + // 3. 读 LCR(0x2518) 后写 0xC3 = 8 数据位 / 1 停止位 / 无校验,bit7 允许写分频 + await read(0x95, 0x2518, 0x0000, 2); + await out(0x9a, 0x2518, 0x00c3); + await read(0x95, 0x0706, 0x0000, 2); + // 4. 关硬件流控(0x501F/0xD90A 是 Linux 内核里的固定字) + await out(0xa1, 0x501f, 0xd90a); + await setBaud(9600); + await out(0xa4, 0xffff, 0x0000); // DTR/RTS 全复位 + await read(0x95, 0x0706, 0x0000, 2); + // 5. 切到目标波特率并重新写 LCR,最后拉高 DTR+RTS + await setBaud(opts.baudRate); + await out(0x9a, 0x2518, 0x00c3); + await out(0xa4, 0xff9f, 0x0000); // ~0x0060 = RTS(bit6)+DTR(bit5) 有效 + log('driver', 'CH34x 初始化完成 @' + opts.baudRate + ' bps'); + }, + /** DTR / RTS 控制(request 0xA4,位是取反写入的) */ + setSignals: async function (ctx, sig) { + var mask = 0; + if (sig.requestToSend) mask |= 0x40; // RTS + if (sig.dataTerminalReady) mask |= 0x20; // DTR + await vOut(ctx)(0xa4, (~mask) & 0xffff, 0x0000); + } + }; + + // ───────────────────────────── CDC-ACM ───────────────────────────── + var WCH_CDC_PIDS = [ + 0x55d2, 0x55d3, 0x55d4, 0x55d5, 0x55d6, 0x55d7, 0x55d8, 0x55d9, + 0x55da, 0x55db, 0x55dc, 0x55dd, 0x55de, 0x55df, 0x55e0, 0x55e1, 0x55e2, 0x55e3, 0x55e7 + ]; + + function cdcLineCoding(baud) { + var b = new Uint8Array(7); + new DataView(b.buffer).setUint32(0, baud, true); + b[4] = 0; // 1 stop bit + b[5] = 0; // no parity + b[6] = 8; // 8 data bits + return b; + } + + var cdcAcm = { + name: 'CDC-ACM', + filters: [{ classCode: 0x02 }].concat(WCH_CDC_PIDS.map(function (p) { return { vendorId: CH34X_VID, productId: p }; })), + supports: function (d) { + return (d.configurations || []).some(function (c) { + return findInterfaceByClass(c, 0x02) !== null && findInterfaceByClass(c, 0x0a) !== null; + }); + }, + layout: cdcLayout, + initialize: async function (ctx, opts) { + var ci = ctx.layout.controlInterface; + await ctx.controlOut('class', 'interface', 0x20, 0x0000, ci, cdcLineCoding(opts.baudRate)); // SET_LINE_CODING + await ctx.controlOut('class', 'interface', 0x22, 0x0003, ci); // DTR+RTS + }, + setSignals: async function (ctx, sig) { + var v = (sig.dataTerminalReady ? 1 : 0) | (sig.requestToSend ? 2 : 0); + await ctx.controlOut('class', 'interface', 0x22, v, ctx.layout.controlInterface); + }, + shutdown: function (ctx) { return ctx.controlOut('class', 'interface', 0x22, 0x0000, ctx.layout.controlInterface); } + }; + + // ───────────────────────────── CP210x (Silicon Labs) ───────────────────────────── + var CP210X_VID = 0x10c4; + var CP210X_PIDS = [0xea60, 0xea70, 0xea71]; + + var cp210x = { + name: 'CP210x', + readQueueDepth: 32, + filters: CP210X_PIDS.map(function (p) { return { vendorId: CP210X_VID, productId: p }; }), + supports: function (d) { return d.vendorId === CP210X_VID && CP210X_PIDS.indexOf(d.productId) !== -1; }, + layout: genericLayout, + initialize: async function (ctx, opts) { + var ci = ctx.layout.controlInterface; + var req = function (r, v, data) { return ctx.controlOut('vendor', 'interface', r, v, ci, data); }; + var baud = new Uint8Array(4); + new DataView(baud.buffer).setUint32(0, opts.baudRate, true); + await req(0x00, 0x0001); // IFC_ENABLE + await req(0x07, 0x0300); // SET_MHS + await req(0x13, 0x0000, new Uint8Array(16)); // SET_FLOW (关流控) + await req(0x1e, 0x0000, baud); // SET_BAUDRATE + await req(0x03, 0x0800); // SET_LINE_CTL 8N1 + await req(0x07, 0x0301); + await req(0x07, 0x0302); + log('driver', 'CP210x 初始化完成 @' + opts.baudRate + ' bps'); + }, + shutdown: async function (ctx) { + var ci = ctx.layout.controlInterface; + await ctx.controlOut('vendor', 'interface', 0x12, 0x000f, ci); + await ctx.controlOut('vendor', 'interface', 0x00, 0x0000, ci); + } + }; + + // ───────────────────────────── FTDI ───────────────────────────── + var FTDI_VID = 0x0403; + var FTDI_PIDS = [0x6001, 0x6010, 0x6011, 0x6014, 0x6015]; + + /** FTDI 的波特率是 48MHz/(n+frac),只能逼近 */ + function calcFtdiBaud(baud) { + if (!Number.isInteger(baud) || baud <= 0 || baud > 3500000) throw new Error('FTDI 不支持的波特率: ' + baud); + var value, frac, actual; + if (baud >= 2500000) { value = 0; frac = 0; actual = 3000000; } + else if (baud >= 1750000) { value = 1; frac = 0; actual = 2000000; } + else { + var n = Math.floor(48000000 / baud); + n = (n + 1) >> 1; + frac = n & 7; + value = n >> 3; + if (value > 16383) throw new Error('FTDI 不支持的波特率: ' + baud); + actual = Math.floor(48000000 / ((value << 3) + frac)); + actual = (actual + 1) >> 1; + } + if (Math.abs(1 - actual / baud) >= 0.031) throw new Error('FTDI 无法精确表示波特率 ' + baud); + var v = value, idx = 0; + switch (frac) { + case 0: break; + case 4: v |= 16384; break; + case 2: v |= 32768; break; + case 1: v |= 49152; break; + case 3: idx = 1; break; + case 5: v |= 16384; idx = 1; break; + case 6: v |= 32768; idx = 1; break; + case 7: v |= 49152; idx = 1; break; + } + return { value: v, fractionalIndex: idx }; + } + + /** FTDI 每 packetSize 字节会插入 2 字节 modem 状态,必须剥掉 */ + function ftdiStripStatus(data, packetSize) { + if (data.length < 2) return new Uint8Array(0); + var out = []; + for (var i = 0; i < data.length; i += packetSize) { + var end = Math.min(i + packetSize, data.length); + for (var j = Math.min(i + 2, end); j < end; j += 1) out.push(data[j] === undefined ? 0 : data[j]); + } + return Uint8Array.from(out); + } + + var ftdi = { + name: 'FTDI', + filters: FTDI_PIDS.map(function (p) { return { vendorId: FTDI_VID, productId: p }; }), + supports: function (d) { return d.vendorId === FTDI_VID && FTDI_PIDS.indexOf(d.productId) !== -1; }, + layout: genericLayout, + transformInput: ftdiStripStatus, + initialize: async function (ctx, opts) { + var idx = ctx.layout.controlInterface + 1; // FTDI 的控制请求打在下一个接口号上 + var req = function (r, v, i) { return ctx.controlOut('vendor', 'device', r, v, i === undefined ? idx : i); }; + await req(0x00, 0x0000); // reset + await req(0x01, 0x0300); // set baud divisor 高位 + await req(0x02, 0x0000); // 低位 + var b = calcFtdiBaud(opts.baudRate); + var major = ctx.device.deviceVersionMajor === undefined ? 0 : ctx.device.deviceVersionMajor; + var needIdx = [7, 8, 9].indexOf(major) !== -1 || ctx.device.configuration.interfaces.length > 1; + await req(0x03, b.value, needIdx ? ((b.fractionalIndex << 8) | idx) : b.fractionalIndex); + await req(0x04, 0x0008); // 8N1 + await req(0x09, 0x0006); // 关流控 + await req(0x01, 0x0101); + await req(0x01, 0x0202); + log('driver', 'FTDI 初始化完成 @' + opts.baudRate + ' bps'); + }, + shutdown: async function (ctx) { + var idx = ctx.layout.controlInterface + 1; + await ctx.controlOut('vendor', 'device', 0x00, 0x0001, idx); + await ctx.controlOut('vendor', 'device', 0x00, 0x0002, idx); + } + }; + + // ───────────────────────────── PL2303 (Prolific) ───────────────────────────── + var PL2303_VID = 0x067b; + var PL2303_PIDS = [0x2303, 0x23a3, 0x23b3, 0x23c3, 0x23d3, 0x23e3, 0x23f3]; + + function pl2303LineCoding(baud) { + var b = new Uint8Array(7); + new DataView(b.buffer).setUint32(0, baud, true); + b[4] = 0; b[5] = 0; b[6] = 8; + return b; + } + + /** 判断 PL2303 变种:HXN / HX / HX 新片 / 01 */ + function pl2303Detect(ctx) { + var dev = ctx.device; + if (dev.productId !== 0x2303) return Promise.resolve('HXN'); + if (dev.deviceClass === 0x02) return Promise.resolve('01'); + if (dev.usbVersionMajor === 2) { + if (dev.deviceVersionMajor === 3 || dev.deviceVersionMajor === 5) { + return ctx.controlIn('vendor', 'device', 0x01, 0x8080, 0x0000, 1).then(function () { return 'T'; })['catch'](function () { return 'HXN'; }); + } + return Promise.resolve('HXN'); + } + return Promise.resolve('HX'); + } + + var pl2303 = { + name: 'PL2303', + filters: PL2303_PIDS.map(function (p) { return { vendorId: PL2303_VID, productId: p }; }), + supports: function (d) { return d.vendorId === PL2303_VID && PL2303_PIDS.indexOf(d.productId) !== -1; }, + layout: genericLayout, + initialize: async function (ctx, opts) { + var type = await pl2303Detect(ctx); + ctx.pl2303Type = type; + var write = function (req, val) { return ctx.controlOut('vendor', 'device', type === 'HXN' ? 0x80 : 0x01, val, req); }; + var read = function (req, val) { return ctx.controlIn('vendor', 'device', type === 'HXN' ? 0x81 : 0x01, val, req, 1); }; + var vendor = function (req, val) { return type === 'HXN' ? write(req, val) : null; }; + var clearHalt = async function () { + if (type === 'HXN') await write(0x0007, 0x0003); + else { await write(0x0008, 0x0000); await write(0x0009, 0x0000); } + }; + await clearHalt(); + if (type !== 'HXN') { + await read(0x8484, 0x0000); + await write(0x0404, 0x0000); + await read(0x8484, 0x0000); + await read(0x8383, 0x0000); + await read(0x8484, 0x0000); + await write(0x0404, 0x0001); + await read(0x8484, 0x0000); + await read(0x8383, 0x0000); + await write(0x0000, 0x0001); + await write(0x0001, 0x0000); + await write(0x0002, type === '01' ? 0x0024 : 0x0044); + } + await ctx.controlOut('class', 'interface', 0x22, 0x0000, 0x0000); // 先复位控制线 + if (type === 'HXN') await write(0x000a, 0x00ff); + else await write(0x0000, 0x0000); + await ctx.controlOut('class', 'interface', 0x20, 0x0000, 0x0000, pl2303LineCoding(opts.baudRate)); + await clearHalt(); + await ctx.controlOut('class', 'interface', 0x22, 0x0003, 0x0000); + log('driver', 'PL2303 初始化完成 (' + type + ') @' + opts.baudRate + ' bps'); + }, + shutdown: async function (ctx) { + var type = ctx.pl2303Type; + if (!type) return; + if (type === 'HXN') await ctx.controlOut('vendor', 'device', 0x80, 0x0003, 0x0007); + else { await ctx.controlOut('vendor', 'device', 0x01, 0x0000, 0x0008); await ctx.controlOut('vendor', 'device', 0x01, 0x0000, 0x0009); } + } + }; + + // ───────────────────────────── 驱动注册表 ───────────────────────────── + var DRIVERS = [ch34x, cdcAcm, cp210x, ftdi, pl2303]; + api.drivers = DRIVERS; + api.calcCh34xBaud = calcCh34xBaud; + api.calcFtdiBaud = calcFtdiBaud; + api.hasWebUSB = hasWebUSB; + api.hasWebSerial = hasWebSerial; + api.isAndroid = isAndroid; + + /** 合并所有驱动的 filters,用于 requestDevice(去重) */ + function allFilters() { + var seen = {}, out = []; + DRIVERS.forEach(function (d) { + (d.filters || []).forEach(function (f) { + var k = JSON.stringify(f); + if (!seen[k]) { seen[k] = 1; out.push(f); } + }); + }); + return out; + } + api.supportedFilters = allFilters; + + /** 只按 CH34x 过滤(默认;安卓上弹窗列表更干净) */ + function ch34xFilters() { return ch34x.filters.slice(); } + api.ch34xFilters = ch34xFilters; + + api.chipName = function (vid, pid) { + if (vid === CH34X_VID) { + if (pid === 0x7523 || pid === 0x5523) return 'ch340'; + if (WCH_CDC_PIDS.indexOf(pid) !== -1) return 'ch34x-cdc'; + } + if (vid === CP210X_VID) return 'cp210x'; + if (vid === FTDI_VID) return 'ftdi'; + if (vid === PL2303_VID) return 'pl2303'; + return 'unknown'; + }; + + function pickDriver(device) { + for (var i = 0; i < DRIVERS.length; i++) { + try { if (DRIVERS[i].supports(device)) return DRIVERS[i]; } catch (e) { /* 某些设备描述符残缺,跳过 */ } + } + return null; + } + api.pickDriver = pickDriver; + + // ───────────────────────────── 端口对象(Web Serial 兼容) ───────────────────────────── + /** + * 暴露给业务的端口对象,接口与 Web Serial 的 SerialPort 对齐: + * open({baudRate,...}) / close() / getInfo() / readable / writable / setSignals() / forget() + * 另加:device(原始 USBDevice)、chip、addEventListener('disconnect') + */ + function UsbSerialPort(device, driver, defaults) { + this.device = device; + this.driver = driver; + this.options = defaults || {}; + this.readable = null; + this.writable = null; + this.opened = false; + this.closing = false; + this.claimedInterfaces = []; + this.context = null; + this.reader = null; + this.writer = null; + this.chip = api.chipName(device.vendorId, device.productId); + this.readDiagnostics = { transfers: 0, bytes: 0, transferSize: 0 }; + this._readQueue = []; + this._listeners = { disconnect: [] }; + this._onUsbDisconnect = null; + } + + UsbSerialPort.prototype.getInfo = function () { + return { usbVendorId: this.device.vendorId, usbProductId: this.device.productId }; + }; + UsbSerialPort.prototype.getDiagnostics = function () { + return { transfers: this.readDiagnostics.transfers, bytes: this.readDiagnostics.bytes, transferSize: this.readDiagnostics.transferSize }; + }; + UsbSerialPort.prototype.addEventListener = function (type, fn) { + if (this._listeners[type]) this._listeners[type].push(fn); + }; + UsbSerialPort.prototype.removeEventListener = function (type, fn) { + if (!this._listeners[type]) return; + this._listeners[type] = this._listeners[type].filter(function (f) { return f !== fn; }); + }; + UsbSerialPort.prototype._emit = function (type, ev) { + (this._listeners[type] || []).forEach(function (fn) { try { fn(ev); } catch (e) { log('warn', 'disconnect 回调异常: ' + e.message); } }); + }; + + UsbSerialPort.prototype.open = async function (openOptions) { + var opts = { + baudRate: DEFAULT_BAUD, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' + }; + var k; + for (k in this.options) if (Object.prototype.hasOwnProperty.call(this.options, k)) opts[k] = this.options[k]; + for (k in (openOptions || {})) if (Object.prototype.hasOwnProperty.call(openOptions, k)) opts[k] = openOptions[k]; + + if (this.opened) throw new Error('端口已经打开'); + if (opts.dataBits !== 8 || opts.stopBits !== 1 || opts.parity !== 'none') { + throw new Error('浏览器 USB 通道目前只支持 8N1'); + } + log('open', this.driver.name + ' @' + opts.baudRate + ' bps'); + this.closing = false; + this.readDiagnostics = { transfers: 0, bytes: 0, transferSize: 0 }; + + var device = this.device; + try { + if (!device.opened) await device.open(); + if (!device.configuration) { + var cfg = device.configurations && device.configurations[0]; + if (!cfg) throw new Error('USB 设备没有可用配置'); + await device.selectConfiguration(cfg.configurationValue); + } + var configuration = device.configuration; + if (!configuration) throw new Error('读取 USB 配置失败'); + var layout = this.driver.layout(configuration); + this.layout = layout; + var uniq = layout.claimedInterfaces.filter(function (v, i, a) { return a.indexOf(v) === i; }); + for (var i = 0; i < uniq.length; i++) { + try { + await device.claimInterface(uniq[i]); + } catch (e) { + throw new Error('无法占用 USB 接口 ' + uniq[i] + '(可能被系统驱动/其它程序占用):' + (e && e.message ? e.message : e)); + } + this.claimedInterfaces.push(uniq[i]); + } + var alts = layout.interfaceAlternates || []; + for (var j = 0; j < alts.length; j++) { + if (alts[j].alternateSetting !== 0) await device.selectAlternateInterface(alts[j].interfaceNumber, alts[j].alternateSetting); + } + this.context = new ControlContext(device, layout); + this.context.device = device; + await this.driver.initialize(this.context, opts); + this._createStreams(opts); + this.opened = true; + this._watchUnplug(); + log('open', '已连接 ' + this.driver.name + ' (VID 0x' + device.vendorId.toString(16) + ' PID 0x' + device.productId.toString(16) + ')'); + return this; + } catch (e) { + await this._cleanup(false); + throw e; + } + }; + + UsbSerialPort.prototype._watchUnplug = function () { + if (!hasWebUSB() || typeof navigator.usb.addEventListener !== 'function') return; + var self = this; + this._onUsbDisconnect = function (ev) { + if (ev && ev.device && ev.device !== self.device) return; + log('warn', 'USB 设备已拔出'); + self.closing = true; + self.opened = false; + self._emit('disconnect', { type: 'disconnect', target: self }); + }; + navigator.usb.addEventListener('disconnect', this._onUsbDisconnect); + }; + + UsbSerialPort.prototype._createStreams = async function (opts) { + var self = this; + var layout = this.layout; + var epIn = layout.input, epOut = layout.output; + var packetSize = Math.max(1, epIn.packetSize || 64); + var transferSize = Math.max(packetSize, opts.readTransferSize || this.driver.readTransferSize || packetSize); + var queueDepth = Math.max(1, opts.readQueueDepth || this.driver.readQueueDepth || 4); + this.readDiagnostics.transferSize = transferSize; + + var queue = []; + this._readQueue = queue; + var refill = function () { + queue.push(self.device.transferIn(epIn.endpointNumber, transferSize).then( + function (result) { return { result: result }; }, + function (error) { return { error: error }; } + )); + }; + + this.readable = new ReadableStream({ + start: function () { for (var i = 0; i < queueDepth; i++) refill(); }, + pull: async function (controller) { + if (self.closing) { try { controller.close(); } catch (e) {} return; } + try { + var pending = queue.shift(); + if (!pending) throw new Error('USB 读队列为空'); + var settled = await pending; + if (self.closing) { try { controller.close(); } catch (e) {} return; } + if (settled.error) throw settled.error; + refill(); + var res = settled.result; + if (res.status !== 'ok') throw new Error('USB bulk 读失败 (' + res.status + ')'); + self.readDiagnostics.transfers++; + self.readDiagnostics.bytes += (res.data && res.data.byteLength) || 0; + if (res.data && res.data.byteLength > 0) { + var chunk = new Uint8Array(res.data.buffer, res.data.byteOffset, res.data.byteLength).slice(); + if (self.driver.transformInput) chunk = self.driver.transformInput(chunk, packetSize); + if (chunk.length > 0) controller.enqueue(chunk); + } + } catch (err) { + if (self.closing) { try { controller.close(); } catch (e) {} } else { try { controller.error(err); } catch (e) {} } + } + }, + cancel: function () { self.closing = true; } + }, { highWaterMark: 0 }); + + this.writable = new WritableStream({ + write: async function (chunk) { + var data = toU8(chunk); + var offset = 0; + while (offset < data.length) { + var slice = data.subarray(offset, Math.min(offset + WRITE_CHUNK, data.length)); + var res = await self.device.transferOut(epOut.endpointNumber, slice); + if (res.status !== 'ok') throw new Error('USB bulk 写失败 (' + res.status + ')'); + var written = res.bytesWritten === undefined ? slice.length : res.bytesWritten; + if (written <= 0 || written > slice.length) throw new Error('USB bulk 写没有进展'); + offset += written; + } + } + }); + }; + + /** 便捷:写一段数据(首次调用会持有 writer,和直接玩 writable 二选一) */ + UsbSerialPort.prototype.write = async function (data) { + if (!this.opened) throw new Error('端口还没打开'); + if (!this.writer) this.writer = this.writable.getWriter(); + await this.writer.write(toU8(data)); + }; + + /** 便捷:读一段数据,timeoutMs 内没数据返回 null */ + UsbSerialPort.prototype.read = async function (timeoutMs) { + if (!this.opened) throw new Error('端口还没打开'); + if (!this.reader) this.reader = this.readable.getReader(); + var reader = this.reader; + if (!timeoutMs) { + var r = await reader.read(); + return r.done ? null : r.value; + } + var timer = null; + var timeout = new Promise(function (resolve) { timer = setTimeout(function () { resolve({ timeout: true }); }, timeoutMs); }); + var result = await Promise.race([reader.read().then(function (r) { return { read: r }; }), timeout]); + if (timer) clearTimeout(timer); + if (result.timeout) return null; + return result.read.done ? null : result.read.value; + }; + + UsbSerialPort.prototype.setSignals = async function (signals) { + if (!this.opened || !this.context) throw new Error('端口还没打开'); + if (!this.driver.setSignals) throw new Error(this.driver.name + ' 不支持 DTR/RTS 控制'); + await this.driver.setSignals(this.context, signals || {}); + }; + + UsbSerialPort.prototype.forget = async function () { + if (this.device.forget) await this.device.forget(); + }; + + UsbSerialPort.prototype.close = async function () { + if (!this.opened && this.claimedInterfaces.length === 0) return; + this.closing = true; + this.readable = null; + this.writable = null; + await this._cleanup(true); + this.opened = false; + }; + + UsbSerialPort.prototype._cleanup = async function (withShutdown) { + if (this._onUsbDisconnect && hasWebUSB() && typeof navigator.usb.removeEventListener === 'function') { + try { navigator.usb.removeEventListener('disconnect', this._onUsbDisconnect); } catch (e) {} + } + this._onUsbDisconnect = null; + try { if (this.reader) { try { this.reader.releaseLock(); } catch (e) {} this.reader = null; } } catch (e) {} + try { if (this.writer) { try { this.writer.releaseLock(); } catch (e) {} this.writer = null; } } catch (e) {} + if (withShutdown && this.context && this.driver.shutdown) { + try { await this.driver.shutdown(this.context); } catch (e) { log('warn', 'shutdown 失败: ' + (e && e.message)); } + } + this.context = null; + var claimed = this.claimedInterfaces.slice().reverse(); + this.claimedInterfaces = []; + for (var i = 0; i < claimed.length; i++) { + try { await this.device.releaseInterface(claimed[i]); } catch (e) {} + } + this.opened = false; + try { await this.device.close(); } catch (e) {} + log('close', '端口已关闭'); + }; + + api.UsbSerialPort = UsbSerialPort; + + // ───────────────────────────── 连接入口 ───────────────────────────── + /** 弹系统选设备框,返回未打开的端口对象(Web Serial 同款语义) */ + api.requestPort = async function (options) { + if (!hasWebUSB()) throw new Error('这个浏览器没有 WebUSB(iOS 不支持,安卓/桌面请用 Chrome/Edge)'); + options = options || {}; + var filters = options.filters || (options.allDrivers ? allFilters() : ch34xFilters()); + log('chooser', '等待用户选择 USB 设备… filters=' + JSON.stringify(filters)); + var device = await navigator.usb.requestDevice({ filters: filters }); + return api.fromDevice(device, options); + }; + + /** 用已授权(getDevices)的设备直接建端口 */ + api.fromDevice = function (device, options) { + var driver = pickDriver(device); + if (!driver) { + throw new Error('没有匹配的浏览器端驱动: VID 0x' + device.vendorId.toString(16) + ' PID 0x' + device.productId.toString(16)); + } + return new UsbSerialPort(device, driver, options || {}); + }; + + /** 之前授权过的设备(拔插后免再次弹窗) */ + api.getAuthorizedDevices = async function (options) { + if (!hasWebUSB()) return []; + var filters = (options && options.filters) || (options && options.allDrivers ? allFilters() : ch34xFilters()); + var devices = await navigator.usb.getDevices(); + return devices.filter(function (d) { + if (pickDriver(d)) return true; + return filters.some(function (f) { + if (f.vendorId !== undefined && f.vendorId !== d.vendorId) return false; + if (f.productId !== undefined && f.productId !== d.productId) return false; + return f.vendorId !== undefined; + }); + }); + }; + + api.getAuthorizedPorts = async function (options) { + var devices = await api.getAuthorizedDevices(options); + return devices.map(function (d) { return api.fromDevice(d, options); }); + }; + + /** 一键连接:选设备 + 打开(安卓上就是这两步) */ + api.connect = async function (options) { + var port = await api.requestPort(options); + await port.open(options || {}); + return port; + }; + + return api; +}); diff --git a/docs/ch34x-registers.md b/docs/ch34x-registers.md new file mode 100644 index 0000000..c8d241f --- /dev/null +++ b/docs/ch34x-registers.md @@ -0,0 +1,106 @@ +# CH34x 寄存器与初始化序列(逆向 + 内核对照) + +本驱动没有用到任何私有 SDK:全部控制传输都对齐 Linux 内核 `drivers/usb/serial/ch341.c` +以及 bbamorachi 站点(tools.bbamorachi.us)前端 bundle 中的实现(两者一致)。 + +## 设备标识 + +| 芯片 | VID | PID | 接口类 | 浏览器端驱动 | +|---|---|---|---|---| +| CH340 / CH340G / CH340C / CH340B | `0x1A86` | `0x7523` | `0xFF` vendor-specific | `ch34x` | +| CH341 / CH341A(串口模式) | `0x1A86` | `0x5523` | `0xFF` | `ch34x` | +| 部分 CH341A 克隆 | `0x1A86` | `0x5512` / `0x7522` | `0xFF` | `ch34x`(默认接受) | +| CH343 / CH9102 / CH347(CDC 模式) | `0x1A86` | `0x55D2…0x55E7` | CDC-ACM | `cdcAcm` | + +CH340 只有 **一个接口、两个 bulk 端点**(OUT `0x02`、IN `0x82`,wMaxPacketSize = 32)。 +端点号不写死,由 `layout()` 从配置描述符里找:"第一个同时带 bulk IN 和 bulk OUT 的接口"。 + +## 控制传输(vendor / device) + +| 请求 | 名称 | 作用 | +|---|---|---| +| `0x5F` | READ_VERSION | 读 2 字节版本,第 2 字节必须为 0,否则芯片型号/固件不对 | +| `0x95` | READ_REG | 读寄存器:`value` = 寄存器地址,长度 2 | +| `0x9A` | WRITE_REG | 写寄存器:`value` = 寄存器地址,`index` = 数据 | +| `0xA1` | SERIAL_INIT | 串口初始化 / modem 控制(`0x501F,0xD90A` = 关流控) | +| `0xA4` | MODEM_CTRL | DTR/RTS,**位取反写入**:`~((rts?0x40:0)|(dtr?0x20:0))` | + +关键寄存器: + +| 寄存器 | 含义 | +|---|---| +| `0x1312` | 波特率分频高位(含预分频标志位) | +| `0x0F2C` | 波特率分频低位 | +| `0x2518` | LCR:`0xC3` = 8 数据位 + 1 停止位 + 无校验 + bit7 允许写分频 | +| `0x0706` | 状态寄存器(初始化时读一下做同步) | +| `0xFFFF` / `0xFF9F`(作为 `0xA4` 的 value) | modem 控制字,见上 | + +## 波特率算法 + +```js +factor = Math.floor(1532620800 / baud); +ps = 3; +while (factor > 65520 && ps > 0) { factor >>= 3; ps -= 1; } +if (factor > 65520) throw new Error('Unsupported CH34x baud rate'); +factor = 65536 - factor; +ps |= 0x80; // 预分频标志 +write(0x9A, 0x1312, (factor & 0xFF00) | ps); // 高位 +write(0x9A, 0x0F2C, factor & 0xFF); // 低位 +``` + +常用值的计算结果(已由 `test/driver.test.mjs` 断言): + +| 波特率 | 0x1312 | 0x0F2C | +|---|---|---| +| 9600 | `0xB282` | `0x0C` | +| 115200 | `0xCC83` | `0x08` | +| 460800 | `0xF383` | `0x02` | + +基准时钟 1532620800 Hz = 48 MHz × 32(`CH341_BAUDBASE_FACTOR`),与内核一致。 + +## 完整初始化序列(本驱动实现) + +``` +1 0x5F 读版本(2B) → [1] 必须为 0 +2 0xA1 0000 0000 → 清 modem 控制 +3 0x9A 1312 <9600 factor> → 先按 9600 配置(芯片上电默认收发格式) +4 0x9A 0F2C <9600 divisor> +5 0x95 2518 (读 2B) → 读回 LCR 做同步 +6 0x9A 2518 00C3 → 写 LCR = 8N1 +7 0x95 0706 (读 2B) +8 0xA1 501F D90A → 关硬件流控 +9 0x9A 1312 <目标波特率 factor> +10 0x9A 0F2C <目标波特率 divisor> +11 0xA4 FFFF 0000 → DTR/RTS 复位 +12 0x95 0706 (读 2B) +13 0x9A 1312 <目标波特率 factor> ← 与 9/10 重复一次,确保切换生效 +14 0x9A 0F2C <目标波特率 divisor> +15 0x9A 2518 00C3 +16 0xA4 FF9F 0000 → DTR+RTS 有效(`~0x0060`) +``` + +共 16 次控制传输,测试里逐条比对。 + +## CDC-ACM(CH343 / CH9102 / CH347 CDC 模式) + +标准 USB CDC 请求,不用厂商私有寄存器: + +| 请求 | 名称 | 数据 | +|---|---|---| +| `0x20` (class/interface) | SET_LINE_CODING | 7 字节:波特率 uint32 小端 + 停止位 + 校验 + 数据位(`00 00 08` = 8N1) | +| `0x22` (class/interface) | SET_CONTROL_LINE_STATE | `0x0003` = DTR+RTS,`0x0000` = 全部释放 | + +## 数据通道 + +- **读**:`transferIn(epIn, packetSize)`,默认预排队 **32** 个请求(`readQueueDepth`), + 通过 `ReadableStream` 暴露;每个 transfer 完成即补一个,安卓上不容易丢数据。 +- **写**:`transferOut(epOut, chunk)`,按 **4096 字节**分片,检查 `bytesWritten` 保证有进展。 +- open 时如果 `device.configuration` 为空,先 `selectConfiguration(configurations[0])`; + 然后 `claimInterface`,非 0 的 alt setting 需要 `selectAlternateInterface`。 + +## 为什么安卓不用装驱动 + +CH340 的接口类标志是 `0xFF`(vendor-specific),安卓/Linux 内核**不会**给它绑定 +`cdc_acm` / `usbserial`,所以设备是"空闲"的,浏览器(用户态)可以 `claimInterface` +并直接收发。CDC-ACM 类设备就没这么幸运:个别 ROM 上内核会先抢走,此时 +`claimInterface` 会失败——驱动里把这种失败翻译成了中文提示。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..64c8435 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "ch340-webusb", + "version": "1.0.0", + "description": "Browser driver for CH340/CH341 (and CDC-ACM/CP210x/FTDI/PL2303) USB-serial chips over WebUSB — works on Android Chrome, plus a navigator.serial shim.", + "main": "ch340-webusb.js", + "files": [ + "ch340-webusb.js", + "ch340-serial-shim.js", + "ch340-test.html", + "docs", + "README.md", + "LICENSE" + ], + "scripts": { + "test": "node test/driver.test.mjs" + }, + "keywords": [ + "webusb", + "ch340", + "ch341", + "serial", + "android", + "web-serial", + "usb-serial" + ], + "license": "MIT" +} diff --git a/test/driver.test.mjs b/test/driver.test.mjs new file mode 100644 index 0000000..86b0182 --- /dev/null +++ b/test/driver.test.mjs @@ -0,0 +1,364 @@ +/** + * ch340-webusb 驱动验证(node,无需硬件) + * 用 mock USBDevice 断言:波特率算法 / 控制传输序列 / 批量收发 / 关闭清理 / CDC 布局 / 垫片 + * 运行: node test/driver.test.mjs + */ +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +// ── 加载驱动(与浏览器
连接设备