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,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