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
+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); }