Files
ch340-webusb/test/driver.test.mjs
T
jiangchen 7af53efaa9 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,无需硬件)
2026-09-23 00:35:13 +08:00

365 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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); }