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,无需硬件)
This commit is contained in:
2026-09-23 00:35:13 +08:00
commit 7af53efaa9
9 changed files with 1854 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+21
View File
@@ -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.
+142
View File
@@ -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
<script src="/ch340-webusb.js?v=1"></script>
<script src="/ch340-serial-shim.js?v=1"></script> <!-- 需要免改业务代码时加载 -->
```
### 方式一:直接用(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 <factor> 波特率(先按 9600 配置)
0x9A 0F2C <divisor>
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
+174
View File
@@ -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)零改动即可在安卓浏览器里跑。
*
* 加载顺序(重要):
* <script src="/ch340-webusb.js?v=1"></script>
* <script src="/ch340-serial-shim.js?v=1"></script> <!-- 放在其它 navigator.serial 垫片之前 -->
* <script src="/bluetooth-serial-shim.js?v=1"></script> <!-- 它仍会暴露 window.__btSerial -->
* <script src="/业务 bundle.js"></script>
*
* 可选配置:加载前设置 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));
}
})();
+265
View File
@@ -0,0 +1,265 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>CH340 WebUSB 测试台 · ch340-webusb</title>
<style>
:root { --bg:#0f1115; --panel:#171a21; --line:#262b36; --fg:#e6e9ef; --dim:#8b93a7; --acc:#4da3ff; --ok:#3ddc84; --err:#ff5f56; --warn:#ffbd2e; }
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
body { margin:0; padding:12px; background:var(--bg); color:var(--fg); font:14px/1.5 -apple-system,system-ui,"Segoe UI",Roboto,"Noto Sans SC",sans-serif; max-width:820px; margin:0 auto; }
h1 { font-size:17px; margin:4px 0 2px; }
.sub { color:var(--dim); font-size:12px; margin-bottom:10px; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:10px; margin-bottom:10px; }
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.row + .row { margin-top:8px; }
button { background:#222836; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:10px 14px; font-size:14px; cursor:pointer; }
button:active { transform:translateY(1px); }
button.primary { background:var(--acc); border-color:var(--acc); color:#06121f; font-weight:600; }
button.danger { background:#3a1c1c; border-color:#5a2a2a; color:#ffb4b0; }
button:disabled { opacity:.45; cursor:not-allowed; }
select, input[type=text], input[type=number] { background:#0d1017; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px; font-size:14px; }
input[type=text] { flex:1; min-width:140px; }
.pill { font-size:12px; padding:3px 9px; border-radius:999px; border:1px solid var(--line); color:var(--dim); }
.pill.on { color:var(--ok); border-color:#1f5c3a; background:#12271c; }
.pill.off { color:var(--err); border-color:#5a2a2a; background:#241414; }
.kv { display:grid; grid-template-columns:auto 1fr; gap:2px 10px; font-size:12.5px; color:var(--dim); }
.kv b { color:var(--fg); font-weight:500; }
#log { background:#0a0c11; border:1px solid var(--line); border-radius:8px; padding:8px; height:230px; overflow:auto; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-all; }
#log .t { color:#5b6478; }
#log .tx { color:var(--acc); }
#log .rx { color:var(--ok); }
#log .sys { color:var(--warn); }
#log .err { color:var(--err); }
label.chk { display:flex; gap:6px; align-items:center; color:var(--dim); font-size:13px; }
.hint { color:var(--dim); font-size:12px; }
.hint code { background:#0d1017; padding:1px 5px; border-radius:4px; color:#c8d0e0; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
@media (max-width:520px){ .grid2 { grid-template-columns:1fr; } }
</style>
</head>
<body>
<h1>CH340 WebUSB 测试台</h1>
<div class="sub">ch340-webusb · 浏览器直连 USB 串口芯片(安卓 WebUSB / 桌面 Web Serial)</div>
<div class="panel">
<div class="row">
<span class="pill" id="p-usb">WebUSB ?</span>
<span class="pill" id="p-ser">Web Serial ?</span>
<span class="pill" id="p-and">平台 ?</span>
<span class="pill" id="p-shim">垫片 ?</span>
</div>
<div class="row" style="margin-top:8px">
<button class="primary" id="btn-connect">连接设备</button>
<button class="danger" id="btn-close" disabled>断开</button>
<button id="btn-forget" disabled>忘记授权</button>
</div>
<div class="row">
<label class="chk">波特率
<select id="baud">
<option>9600</option><option>19200</option><option>38400</option><option>57600</option>
<option>115200</option><option>230400</option><option selected>460800</option><option>921600</option>
</select>
</label>
<label class="chk"><input type="checkbox" id="dtr"> DTR</label>
<label class="chk"><input type="checkbox" id="rts"> RTS</label>
<label class="chk"><input type="checkbox" id="dbg"> 调试日志</label>
</div>
<div class="kv" style="margin-top:8px">
<span>芯片</span><b id="k-chip">—</b>
<span>VID/PID</span><b id="k-vid">—</b>
<span>端点</span><b id="k-ep">—</b>
<span>收/发</span><b id="k-stat">0 B / 0 B</b>
</div>
</div>
<div class="panel">
<div class="row">
<input type="text" id="tx" placeholder="要发送的文本,例如 hello 或 01 02 0A(HEX 模式)">
<label class="chk"><input type="checkbox" id="hex"> HEX</label>
<label class="chk"><input type="checkbox" id="crlf" checked> 加 \r\n</label>
</div>
<div class="row">
<button class="primary" id="btn-send" disabled>发送</button>
<button id="btn-loop" disabled>回环自测(TX-RX 短接)</button>
<button id="btn-clear">清空日志</button>
<label class="chk"><input type="checkbox" id="hexlog"> 日志用 HEX</label>
</div>
</div>
<div class="panel">
<div id="log"></div>
<div class="hint" style="margin-top:8px">
安卓:Chrome/Edge + OTG 线,页面必须 HTTPS;首次连接会弹两次确认(Chrome 选择设备 + 安卓 USB 权限)。<br>
桌面:Chrome/Edge 走 Web Serial,本页 <code>连接设备</code> 会优先用原生串口,失败再回退 WebUSB。<br>
看不到设备?确认线是<b>数据线</b>(不是纯充电线)、玩具已开机、且没被别的 App 占用。
</div>
</div>
<script src="./ch340-webusb.js?v=1"></script>
<script src="./ch340-serial-shim.js?v=1"></script>
<script>
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var port = null, reader = null, writer = null, useWebUSBDirect = false;
var rxBytes = 0, txBytes = 0;
function ts() { var d = new Date(); return d.toTimeString().slice(0, 8) + '.' + String(d.getMilliseconds()).padStart(3, '0'); }
function line(cls, tag, msg) {
var el = document.createElement('div');
el.innerHTML = '<span class="t">' + ts() + '</span> <span class="' + cls + '">' + tag + '</span> ' + msg;
$('log').appendChild(el);
$('log').scrollTop = $('log').scrollHeight;
}
function hex(bytes) { return Array.from(bytes).map(function (b) { return b.toString(16).padStart(2, '0'); }).join(' '); }
function printable(bytes) {
var s = ''; for (var i = 0; i < bytes.length; i++) { var b = bytes[i]; s += (b >= 32 && b < 127) ? String.fromCharCode(b) : '.'; }
return s;
}
function render(bytes, dir) {
var isHex = $('hexlog').checked;
var body = isHex ? hex(bytes) : (printable(bytes) + ' [' + bytes.length + 'B]');
line(dir === 'rx' ? 'rx' : 'tx', dir === 'rx' ? '←' : '→', body);
}
// 状态显示
var T = window.TamaCh340;
$('p-usb').textContent = 'WebUSB ' + (T && T.hasWebUSB() ? '可用' : '不可用');
$('p-usb').className = 'pill ' + (T && T.hasWebUSB() ? 'on' : 'off');
$('p-ser').textContent = 'Web Serial ' + (T && T.hasWebSerial() ? '可用' : '不可用');
$('p-ser').className = 'pill ' + (T && T.hasWebSerial() ? 'on' : 'off');
var and = T && T.isAndroid();
$('p-and').textContent = and ? '安卓' : '桌面';
var shim = window.TamaSerialShim;
$('p-shim').textContent = '垫片 ' + (shim && shim.installed ? '已装' : '未装');
$('p-shim').className = 'pill ' + (shim && shim.installed ? 'on' : '');
line('sys', 'SYS', '驱动 ' + (T ? T.version : '?') + ' 已加载;' + (shim && shim.installed ? 'navigator.serial 垫片已安装' : (shim && shim.reason ? '垫片未安装(' + shim.reason + ')' : '')));
if (T) T.onLog = function () { if ($('dbg').checked) line('sys', 'DBG', Array.prototype.slice.call(arguments).join(' ')); };
function getBaud() { return parseInt($('baud').value, 10) || 115200; }
function setConnected(on) {
$('btn-connect').disabled = on;
$('btn-close').disabled = !on;
$('btn-send').disabled = !on;
$('btn-loop').disabled = !on;
$('btn-forget').disabled = !on;
}
async function pump() {
while (port && port.readable) {
try {
var r = await reader.read();
if (r.done) break;
if (r.value && r.value.length) { rxBytes += r.value.length; render(r.value, 'rx'); }
} catch (e) { if (port) line('err', 'ERR', '读取出错: ' + e.message); break; }
$('k-stat').textContent = rxBytes + ' B / ' + txBytes + ' B';
}
}
// 优先 Web Serial(桌面),否则 WebUSB;安卓上垫片会让 navigator.serial 存在,两条路等价
async function connect() {
if (!$('dbg').checked) T.debug = false;
try {
var baud = getBaud();
if (!useWebUSBDirect && navigator.serial) {
line('sys', 'SYS', '使用 Web Serial 请求端口…');
port = await navigator.serial.requestPort();
} else {
line('sys', 'SYS', '使用 WebUSB 请求设备(只列 CH340/CH341)…');
port = await T.requestPort({ baudRate: baud });
}
var info = port.getInfo ? port.getInfo() : {};
await port.open({ baudRate: baud, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' });
writer = port.writable.getWriter();
reader = port.readable.getReader();
setConnected(true);
rxBytes = txBytes = 0;
$('k-chip').textContent = (port.chip || T.chipName(info.usbVendorId, info.usbProductId) || '?');
$('k-vid').textContent = '0x' + (info.usbVendorId || 0).toString(16).padStart(4, '0') + ' / 0x' + (info.usbProductId || 0).toString(16).padStart(4, '0');
$('k-ep').textContent = port.layout ? ('IN 0x' + port.layout.input.endpointNumber.toString(16) + ' / OUT 0x' + port.layout.output.endpointNumber.toString(16) + ' / 包 ' + port.layout.input.packetSize + 'B') : 'Web Serial 原生';
line('sys', 'SYS', '已打开 @' + baud + ' bps, 8N1');
pump();
} catch (e) {
line('err', 'ERR', '连接失败: ' + (e && e.message ? e.message : e));
if (e && e.name === 'NotFoundError') line('sys', 'SYS', '用户取消或没选中设备');
if (/claim|占用/.test(e && e.message || '')) line('sys', 'SYS', '接口被占用:安卓上某些 ROM 的内核驱动会抢 CDC 设备;CH340 一般不受影响');
}
}
async function disconnect() {
try { if (reader) { await reader.cancel().catch(function () {}); reader = null; } } catch (e) {}
try { if (writer) { writer.releaseLock(); writer = null; } } catch (e) {}
try { if (port) { await port.close(); } } catch (e) {}
port = null; setConnected(false);
$('k-ep').textContent = '—'; $('k-stat').textContent = '0 B / 0 B';
line('sys', 'SYS', '已断开');
}
$('btn-connect').onclick = connect;
$('btn-close').onclick = disconnect;
$('btn-forget').onclick = async function () {
if (!port || !port.forget) return line('err', 'ERR', '这个传输方式不支持 forget');
await disconnect(); await port.forget(); line('sys', 'SYS', '已清除该设备的授权记录');
};
$('btn-clear').onclick = function () { $('log').innerHTML = ''; };
function buildPayload() {
var text = $('tx').value;
var out;
if ($('hex').checked) {
var parts = text.trim().split(/[^0-9a-fA-F]+/).filter(Boolean);
out = Uint8Array.from(parts.map(function (h) { return parseInt(h, 16) & 0xff; }));
} else {
out = new TextEncoder().encode(text);
if ($('crlf').checked) {
var withCrlf = new Uint8Array(out.length + 2); withCrlf.set(out); withCrlf[out.length] = 13; withCrlf[out.length + 1] = 10; out = withCrlf;
}
}
return out;
}
$('btn-send').onclick = async function () {
if (!writer) return;
try {
var data = buildPayload();
if (!data.length) return;
await writer.write(data);
txBytes += data.length;
render(data, 'tx');
$('k-stat').textContent = rxBytes + ' B / ' + txBytes + ' B';
} catch (e) { line('err', 'ERR', '发送失败: ' + e.message); }
};
$('btn-loop').onclick = async function () {
if (!writer) return;
line('sys', 'SYS', '回环测试:把 TX 和 RX 短接后应能原样收回 16 字节');
var payload = Uint8Array.from([0x55, 0xaa, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e]);
var got = [];
var tmp = port.readable.getReader();
var collect = (async function () {
var deadline = Date.now() + 1500;
while (Date.now() < deadline && got.length < payload.length) {
var r = await Promise.race([tmp.read(), new Promise(function (res) { setTimeout(function () { res({ timeout: true }); }, Math.max(1, deadline - Date.now())); })]);
if (r.timeout) break;
if (r.done) break;
for (var i = 0; i < r.value.length; i++) got.push(r.value[i]);
}
})();
try { await writer.write(payload); txBytes += payload.length; render(payload, 'tx'); } catch (e) { line('err', 'ERR', '写入失败: ' + e.message); }
await collect;
try { tmp.releaseLock(); } catch (e) {}
if (got.length === payload.length && got.every(function (v, i) { return v === payload[i]; })) line('sys', 'SYS', '回环通过 ✓ 驱动收发正常');
else line('err', 'ERR', '回环未通过(收到 ' + got.length + ' 字节:' + hex(Uint8Array.from(got)) + ')—— 检查 TX/RX 是否短接');
};
$('dtr').onchange = $('rts').onchange = async function () {
if (!port || !port.setSignals) return;
try { await port.setSignals({ dataTerminalReady: $('dtr').checked, requestToSend: $('rts').checked }); }
catch (e) { line('err', 'ERR', '设置 DTR/RTS 失败: ' + e.message); }
};
})();
</script>
</body>
</html>
+752
View File
@@ -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;
});
+106
View File
@@ -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` 会失败——驱动里把这种失败翻译成了中文提示。
+27
View File
@@ -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"
}
+364
View File
@@ -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';
// ── 加载驱动(与浏览器 <script> 完全一致的加载方式) ──
function loadApi() {
const src = readFileSync(new URL('../ch340-webusb.js', import.meta.url), 'utf8');
const sandbox = {
console,
TextEncoder, TextDecoder, DataView, Uint8Array, Promise, setTimeout, clearTimeout,
ReadableStream, WritableStream, Number, JSON, Math, Error, Object, Array, String, Boolean,
WeakMap, Map, Set, isNaN, parseInt
};
vm.createContext(sandbox);
vm.runInContext(src, sandbox, { filename: 'ch340-webusb.js' });
if (!sandbox.TamaCh340) throw new Error('驱动没挂到全局');
return sandbox.TamaCh340;
}
const api = loadApi();
// ── mock USBDevice ──
function ch340Config() {
return {
configurationValue: 1,
interfaces: [{
interfaceNumber: 0,
alternates: [{
alternateSetting: 0,
interfaceClass: 0xff,
endpoints: [
{ endpointNumber: 0x81, direction: 'in', type: 'bulk', packetSize: 32 },
{ endpointNumber: 0x02, direction: 'out', type: 'bulk', packetSize: 32 }
]
}]
}]
};
}
function cdcConfig() {
return {
configurationValue: 1,
interfaces: [
{
interfaceNumber: 0,
alternates: [{ alternateSetting: 0, interfaceClass: 0x02, endpoints: [{ endpointNumber: 0x83, direction: 'in', type: 'interrupt', packetSize: 8 }] }]
},
{
interfaceNumber: 1,
alternates: [{
alternateSetting: 0,
interfaceClass: 0x0a,
endpoints: [
{ endpointNumber: 0x81, direction: 'in', type: 'bulk', packetSize: 64 },
{ endpointNumber: 0x02, direction: 'out', type: 'bulk', packetSize: 64 }
]
}]
}
]
};
}
class MockDevice {
constructor(opts = {}) {
this.vendorId = opts.vendorId ?? 0x1a86;
this.productId = opts.productId ?? 0x7523;
this.deviceVersionMajor = opts.deviceVersionMajor ?? 0;
this.usbVersionMajor = opts.usbVersionMajor ?? 2;
this.deviceClass = opts.deviceClass ?? 0;
this._config = opts.configuration || ch340Config();
this.configurations = [this._config];
this.configuration = opts.presetConfiguration ? this._config : null;
this.opened = false;
this.control = []; // 记录全部控制传输
this.bulkOut = []; // 记录 bulk 写
this.calls = [];
this.controlInQueue = []; // 预置控制读应答
this.bulkInQueue = []; // 预置 bulk 读应答
}
async open() { this.opened = true; this.calls.push('open'); }
async close() { this.opened = false; this.calls.push('close'); }
async forget() { this.calls.push('forget'); }
async selectConfiguration(v) {
this.calls.push('selectConfiguration:' + v);
const c = this.configurations.find((x) => x.configurationValue === v);
if (!c) throw new Error('bad config');
this.configuration = c;
}
async claimInterface(n) { this.calls.push('claim:' + n); }
async releaseInterface(n) { this.calls.push('release:' + n); }
async selectAlternateInterface(i, alt) { this.calls.push('alt:' + i + ':' + alt); }
async controlTransferIn(setup, length) {
this.control.push(['in', setup.requestType, setup.recipient, setup.request, setup.value, setup.index, length]);
const preset = this.controlInQueue.shift();
const bytes = preset || new Array(length).fill(0);
const u8 = Uint8Array.from(bytes);
return { status: 'ok', data: new DataView(u8.buffer) };
}
async controlTransferOut(setup, data) {
const u8 = data ? new Uint8Array(data.buffer ? data.buffer : data, data.byteOffset || 0, data.byteLength) : null;
this.control.push(['out', setup.requestType, setup.recipient, setup.request, setup.value, setup.index, u8 ? Array.from(u8) : null]);
return { status: 'ok', bytesWritten: u8 ? u8.byteLength : 0 };
}
async transferIn(ep, len) {
if (this.bulkInQueue.length) {
const chunk = Uint8Array.from(this.bulkInQueue.shift());
return { status: 'ok', data: new DataView(chunk.buffer) };
}
return new Promise(() => {}); // 没数据就一直挂着,模拟真实端点
}
async transferOut(ep, data) {
const u8 = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
this.bulkOut.push({ ep, data: Array.from(u8) });
return { status: 'ok', bytesWritten: u8.byteLength };
}
}
// ── 迷你测试框架 ──
let pass = 0, fail = 0;
const failures = [];
async function t(name, fn) {
try { await fn(); pass++; console.log(' \u2713 ' + name); }
catch (e) { fail++; failures.push(name + ' :: ' + e.message); console.log(' \u2717 ' + name + '\n ' + e.message); }
}
function eq(a, b, msg) {
const sa = JSON.stringify(a), sb = JSON.stringify(b);
if (sa !== sb) throw new Error((msg || 'assert') + '\n got: ' + sa + '\n expected: ' + sb);
}
function ok(v, msg) { if (!v) throw new Error(msg || 'expected truthy'); }
// 期望的 CH340 控制传输序列(对齐 Linux ch341.c / 参考实现)
function expectedCh34xSeq(baudFactor, baudDivisor) {
return [
['in', 'vendor', 'device', 0x5f, 0, 0, 2],
['out', 'vendor', 'device', 0xa1, 0x0000, 0x0000, null],
['out', 'vendor', 'device', 0x9a, 0x1312, 0xb282, null], // 9600 factor
['out', 'vendor', 'device', 0x9a, 0x0f2c, 0x0c, null], // 9600 divisor
['in', 'vendor', 'device', 0x95, 0x2518, 0, 2],
['out', 'vendor', 'device', 0x9a, 0x2518, 0xc3, null],
['in', 'vendor', 'device', 0x95, 0x0706, 0, 2],
['out', 'vendor', 'device', 0xa1, 0x501f, 0xd90a, null],
['out', 'vendor', 'device', 0x9a, 0x1312, 0xb282, null],
['out', 'vendor', 'device', 0x9a, 0x0f2c, 0x0c, null],
['out', 'vendor', 'device', 0xa4, 0xffff, 0x0000, null],
['in', 'vendor', 'device', 0x95, 0x0706, 0, 2],
['out', 'vendor', 'device', 0x9a, 0x1312, baudFactor, null],
['out', 'vendor', 'device', 0x9a, 0x0f2c, baudDivisor, null],
['out', 'vendor', 'device', 0x9a, 0x2518, 0xc3, null],
['out', 'vendor', 'device', 0xa4, 0xff9f, 0x0000, null]
];
}
console.log('\n== ch340-webusb 驱动验证 ==\n');
await t('波特率算法:9600 / 115200 / 460800 与内核一致', () => {
eq(api.calcCh34xBaud(9600), { factor: 0xb282, divisor: 0x0c });
eq(api.calcCh34xBaud(115200), { factor: 0xcc83, divisor: 0x08 });
eq(api.calcCh34xBaud(460800), { factor: 0xf383, divisor: 0x02 });
let threw = false;
try { api.calcCh34xBaud(0); } catch (e) { threw = true; }
ok(threw, '波特率 0 应该抛错');
threw = false;
try { api.calcCh34xBaud(1); } catch (e) { threw = true; }
ok(threw, '波特率 1 超出范围应该抛错');
});
await t('设备识别:CH340 / CH341 / CP210x / FTDI / PL2303', () => {
eq(api.chipName(0x1a86, 0x7523), 'ch340');
eq(api.chipName(0x1a86, 0x5523), 'ch340');
eq(api.chipName(0x10c4, 0xea60), 'cp210x');
eq(api.chipName(0x0403, 0x6001), 'ftdi');
eq(api.chipName(0x067b, 0x2303), 'pl2303');
eq(api.chipName(0x1234, 0x5678), 'unknown');
});
await t('CH340 打开时执行完整初始化序列(115200)', async () => {
const dev = new MockDevice();
const port = api.fromDevice(dev, { baudRate: 115200 });
await port.open({ baudRate: 115200 });
const exp = expectedCh34xSeq(0xcc83, 0x08);
eq(dev.control, exp, '控制传输序列不一致');
eq(dev.calls.slice(0, 3), ['open', 'selectConfiguration:1', 'claim:0']);
ok(port.opened, '端口应处于打开状态');
eq(port.getInfo(), { usbVendorId: 0x1a86, usbProductId: 0x7523 });
await port.close();
});
await t('CH340 初始化序列(460800,拓麻歌子原厂线速率)', async () => {
const dev = new MockDevice();
const port = api.fromDevice(dev, { baudRate: 460800 });
await port.open({ baudRate: 460800 });
eq(dev.control, expectedCh34xSeq(0xf383, 0x02));
await port.close();
});
await t('版本应答异常时拒绝连接', async () => {
const dev = new MockDevice();
dev.controlInQueue.push([0x03, 0x01]); // [1] !== 0
const port = api.fromDevice(dev, {});
let threw = false;
try { await port.open({ baudRate: 115200 }); } catch (e) { threw = /版本应答/.test(e.message); }
ok(threw, '应抛出 CH34x 版本应答异常');
eq(dev.calls.includes('release:0'), true, '失败后要释放接口');
});
await t('只支持 8N1', async () => {
const dev = new MockDevice();
const port = api.fromDevice(dev, {});
let threw = false;
try { await port.open({ baudRate: 115200, parity: 'even' }); } catch (e) { threw = /8N1/.test(e.message); }
ok(threw, '非 8N1 应该报错');
});
await t('批量写:>4096 字节自动分片', async () => {
const dev = new MockDevice();
const port = api.fromDevice(dev, {});
await port.open({ baudRate: 115200 });
const payload = new Uint8Array(10000);
for (let i = 0; i < payload.length; i++) payload[i] = i & 0xff;
await port.write(payload);
eq(dev.bulkOut.map((x) => x.data.length), [4096, 4096, 1808]);
eq(dev.bulkOut.every((x) => x.ep === 0x02), true, '应写到 OUT 端点 0x02');
await port.close();
});
await t('批量读:ReadableStream 正常吐出数据', async () => {
const dev = new MockDevice();
dev.bulkInQueue.push([0x41, 0x42, 0x43], [0x44]);
const port = api.fromDevice(dev, {});
await port.open({ baudRate: 115200 });
const c1 = await port.read(1000);
eq(Array.from(c1), [0x41, 0x42, 0x43]);
const c2 = await port.read(1000);
eq(Array.from(c2), [0x44]);
const none = await port.read(50);
eq(none, null, '超时应返回 null');
eq(port.getDiagnostics().bytes, 4);
await port.close();
});
await t('DTR/RTS 控制(CH340 走 0xA4 取反写入)', async () => {
const dev = new MockDevice();
const port = api.fromDevice(dev, {});
await port.open({ baudRate: 115200 });
dev.control.length = 0;
await port.setSignals({ dataTerminalReady: true, requestToSend: true });
eq(dev.control, [['out', 'vendor', 'device', 0xa4, 0xff9f, 0x0000, null]]);
dev.control.length = 0;
await port.setSignals({ dataTerminalReady: false, requestToSend: false });
eq(dev.control, [['out', 'vendor', 'device', 0xa4, 0xffff, 0x0000, null]]);
await port.close();
});
await t('关闭:释放接口 + 关闭设备(CDC 会先 shutdown)', async () => {
const dev = new MockDevice();
const port = api.fromDevice(dev, {});
await port.open({ baudRate: 115200 });
await port.close();
eq(dev.calls.includes('release:0'), true);
eq(dev.calls[dev.calls.length - 1], 'close');
eq(port.opened, false);
const cdc = new MockDevice({ vendorId: 0x1a86, productId: 0x55d4, configuration: cdcConfig() });
const cport = api.fromDevice(cdc, {});
await cport.open({ baudRate: 115200 });
await cport.close();
const last = cdc.control[cdc.control.length - 1];
eq(last, ['out', 'class', 'interface', 0x22, 0x0000, 0, null], 'CDC 关闭时应下拉 DTR/RTS (控制接口 0)');
});
await t('CDC-ACM:接口布局 + SET_LINE_CODING', async () => {
const dev = new MockDevice({ vendorId: 0x1a86, productId: 0x55d4, configuration: cdcConfig() });
const port = api.fromDevice(dev, {});
eq(port.driver.name, 'CDC-ACM');
await port.open({ baudRate: 115200 });
eq(dev.calls.filter((c) => c.startsWith('claim')).sort(), ['claim:0', 'claim:1']);
const lineCoding = dev.control[0];
eq(lineCoding.slice(0, 6), ['out', 'class', 'interface', 0x20, 0x0000, 0], 'SET_LINE_CODING 参数');
eq(lineCoding[6], [0x00, 0xc2, 0x01, 0x00, 0x00, 0x00, 0x08], '115200 的小端编码 + 8N1');
eq(dev.control[1], ['out', 'class', 'interface', 0x22, 0x0003, 0, null]);
await port.close();
});
await t('FTDI:波特率查表 + modem 状态字节剥离', () => {
eq(api.calcFtdiBaud(115200), { value: 26, fractionalIndex: 0 });
let threw = false;
try { api.calcFtdiBaud(1000001); } catch (e) { threw = true; }
ok(threw === false || threw === true, '边界不做强约束');
const ftdi = api.drivers.find((d) => d.name === 'FTDI');
// 32 字节一包,每包前 2 字节是 modem 状态,要剥掉
const chunk = Uint8Array.from(Array.from({ length: 8 }, (_, i) => i));
eq(Array.from(ftdi.transformInput(chunk, 4)), [2, 3, 6, 7]);
});
await t('未授权/不支持的设备给出明确报错', () => {
let threw = false;
try { api.fromDevice(new MockDevice({ vendorId: 0x2886, productId: 0x802f }), {}); }
catch (e) { threw = /没有匹配的浏览器端驱动/.test(e.message); }
ok(threw, 'P-COM(XIAO) 不该被 CH340 驱动接走');
});
await t('垫片:无 Web Serial 时补出 navigator.serial', async () => {
const shimSrc = readFileSync(new URL('../ch340-serial-shim.js', import.meta.url), 'utf8');
const dev = new MockDevice();
const fakeNav = {
userAgent: 'Mozilla/5.0 (Linux; Android 14) Chrome/131',
usb: {
requestDevice: async (opts) => { fakeNav._filters = opts.filters; return dev; },
getDevices: async () => [dev],
addEventListener() {}
}
};
const sandbox = {
console, TextEncoder, TextDecoder, DataView, Uint8Array, Promise, setTimeout, clearTimeout,
ReadableStream, WritableStream, Number, JSON, Math, Error, Object, Array, String, Boolean,
WeakMap, Map, Set, navigator: fakeNav, TamaCh340: undefined
};
vm.createContext(sandbox);
vm.runInContext(readFileSync(new URL('../ch340-webusb.js', import.meta.url), 'utf8'), sandbox);
vm.runInContext(shimSrc, sandbox);
ok(sandbox.TamaSerialShim.installed, '垫片应自动安装');
ok(fakeNav.serial && typeof fakeNav.serial.requestPort === 'function', 'navigator.serial 应存在');
const port = await fakeNav.serial.requestPort();
eq(fakeNav._filters, [{ vendorId: 0x1a86, productId: 0x7523 }, { vendorId: 0x1a86, productId: 0x5523 }], '默认只筛 CH340');
eq(port.readable, null, 'Web Serial 语义:open 之前 readable 为 null');
await port.open({ baudRate: 115200 });
eq(dev.control.length, 16, 'open 后应完成 16 步 CH340 初始化');
const sig = await port.getSignals();
eq(sig.dataTerminalReady, false);
const ports = await fakeNav.serial.getPorts();
eq(ports.length, 1);
await port.close();
});
await t('垫片 chain 模式:force 时保留原垫片并可让位给蓝牙', async () => {
const shimSrc = readFileSync(new URL('../ch340-serial-shim.js', import.meta.url), 'utf8');
const dev = new MockDevice();
const btPort = { name: 'bt' };
let btCalls = 0;
const fakeNav = {
userAgent: 'Android',
usb: { requestDevice: async () => dev, getDevices: async () => [dev], addEventListener() {} },
serial: { requestPort: async () => { btCalls++; return btPort; }, getPorts: async () => [] }
};
const sandbox = { console, DataView, Uint8Array, Promise, setTimeout, clearTimeout, ReadableStream, WritableStream,
Number, JSON, Math, Error, Object, Array, String, Boolean, WeakMap, Map, Set, navigator: fakeNav };
vm.createContext(sandbox);
vm.runInContext(readFileSync(new URL('../ch340-webusb.js', import.meta.url), 'utf8'), sandbox);
vm.runInContext(shimSrc, sandbox);
eq(sandbox.TamaSerialShim.installed, false, '有 navigator.serial 时默认不接管');
const res = sandbox.TamaSerialShim.install({ force: true, baudRate: 115200 });
eq(res.ok, true, 'force 应接管成功');
eq(sandbox.TamaSerialShim.previous === fakeNav.serial.__previous, true, '原垫片应被保留');
sandbox.__CH340_PREFER = 'bluetooth';
const p = await fakeNav.serial.requestPort();
eq(p === btPort && btCalls === 1, true, 'prefer=bluetooth 时应让位给原垫片');
});
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败\n');
if (fail) { console.log(failures.join('\n')); process.exit(1); }