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:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
});
|
||||||
@@ -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` 会失败——驱动里把这种失败翻译成了中文提示。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
Reference in New Issue
Block a user