Files
ch340-webusb/ch340-test.html
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

266 lines
13 KiB
HTML
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.
<!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>