- 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,无需硬件)
266 lines
13 KiB
HTML
266 lines
13 KiB
HTML
<!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>
|