Files
jiangchen 6c5e647c8a
test / test (push) Canceled after 0s
feat: v1.0.0 收尾 — 五芯片 DTR/RTS、类型声明、示例、CI
- CP210x / FTDI / PL2303 补齐 setSignals()(编码与各自初始化序列一致)
- 新增 index.d.ts(TypeScript 类型声明)、examples/minimal.html(最小接入示例)
- 新增 .gitea/workflows/ci.yml(push 时跑离线单测)、.gitattributes(强制 LF)、CHANGELOG.md
- package.json 补 repository / homepage / bugs / types / engines
- 单测 16 项全部通过:node test/driver.test.mjs
2026-09-23 00:41:29 +08:00

397 lines
17 KiB
JavaScript
Raw Permalink 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('CP210x / FTDI / PL2303 的 DTR/RTS 编码', async () => {
// CP210x: vendor/interface req 0x07, value = 0x0300 | (DTR|RTS)
const cp = new MockDevice({ vendorId: 0x10c4, productId: 0xea60 });
const cport = api.fromDevice(cp, {});
await cport.open({ baudRate: 115200 });
cp.control.length = 0;
await cport.setSignals({ dataTerminalReady: true, requestToSend: true });
eq(cp.control, [['out', 'vendor', 'interface', 0x07, 0x0303, 0, null]]);
cp.control.length = 0;
await cport.setSignals({ dataTerminalReady: false, requestToSend: false });
eq(cp.control, [['out', 'vendor', 'interface', 0x07, 0x0300, 0, null]]);
await cport.close();
// FTDI: vendor/device req 0x01, index = controlInterface+1, value = 0x0300 | (DTR|RTS)
const ft = new MockDevice({ vendorId: 0x0403, productId: 0x6001 });
const fport = api.fromDevice(ft, {});
await fport.open({ baudRate: 115200 });
ft.control.length = 0;
await fport.setSignals({ dataTerminalReady: true, requestToSend: false });
eq(ft.control, [['out', 'vendor', 'device', 0x01, 0x0301, 1, null]]);
await fport.close();
// PL2303: class/interface req 0x22, value = bit0 DTR | bit1 RTS
const pl = new MockDevice({ vendorId: 0x067b, productId: 0x2303, deviceClass: 0x02 });
const pport = api.fromDevice(pl, {});
await pport.open({ baudRate: 115200 });
pl.control.length = 0;
await pport.setSignals({ requestToSend: true, dataTerminalReady: false });
eq(pl.control, [['out', 'class', 'interface', 0x22, 0x0002, 0, null]]);
await pport.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); }