feat: v1.0.0 收尾 — 五芯片 DTR/RTS、类型声明、示例、CI
test / test (push) Canceled after 0s

- 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
This commit is contained in:
2026-09-23 00:41:29 +08:00
parent f02c0b06b8
commit 6c5e647c8a
9 changed files with 271 additions and 10 deletions
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.jpg binary
+12
View File
@@ -0,0 +1,12 @@
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: 运行驱动单测(无需硬件)
run: node test/driver.test.mjs
+18
View File
@@ -0,0 +1,18 @@
# Changelog
## v1.0.0
首个公开版本。
- ch340-webusb.js:WebUSB 上的 USB-Serial 栈
- CH340/CH341(1a86:7523 / 1a86:5523,兼容 5512/7522 克隆)完整初始化序列,对齐 Linux ch341.c
- 波特率分频 calcCh34xBaud();32 深度预排队读 + ReadableStream;4096 分片写 + WritableStream
- DTR/RTS(CH340 用 0xA4 取反写入)、拔插事件、读诊断计数
- 附带 CDC-ACM(CH343/CH9102/CH347)、CP210x、FTDI(含 modem 状态字节剥离)、PL2303(HXN/HX/01/T)
- 五种芯片都实现了 setSignals()(DTR/RTS)
- ch340-serial-shim.js:安卓上补出 navigator.serial,Web Serial 业务代码零改动可用;支持 chain 模式让位给蓝牙垫片
- ch340-test.html:连接 / 收发 / HEX / DTR-RTS / 回环自测 测试台
- examples/minimal.html:最小接入示例
- index.d.ts:TypeScript 类型声明
- docs/ch34x-registers.md:寄存器与控制传输说明
- test/driver.test.mjs:16 项离线断言(mock USBDevice,无需硬件)
+12
View File
@@ -53,6 +53,18 @@ npx serve . # 或 python -m http.server 8080
# 手机访问 https 地址;安卓上建议用 cloudflared/ngrok 暴露本地端口
```
## 仓库结构
| 文件 | 说明 |
|---|---|
| ch340-webusb.js | 核心驱动(UMD:script 标签直接引,也可 require / import) |
| ch340-serial-shim.js | navigator.serial 垫片(安卓零改动接入) |
| ch340-test.html | 测试台(连接 / 收发 / HEX / DTR-RTS / 回环自测) |
| examples/minimal.html | 最小接入示例 |
| index.d.ts | TypeScript 类型声明 |
| docs/ch34x-registers.md | 寄存器与控制传输说明 |
| test/driver.test.mjs | 离线单测(mock USBDevice,无需硬件) |
## API
| 成员 | 说明 |
+16
View File
@@ -268,6 +268,11 @@
await req(0x07, 0x0302);
log('driver', 'CP210x 初始化完成 @' + opts.baudRate + ' bps');
},
/** SET_MHS(0x07):高字节=掩码,低字节=电平;0x03 掩码表示"两条线都由本调用决定" */
setSignals: async function (ctx, sig) {
var bits = (sig.dataTerminalReady ? 0x01 : 0) | (sig.requestToSend ? 0x02 : 0);
await ctx.controlOut('vendor', 'interface', 0x07, 0x0300 | bits, ctx.layout.controlInterface);
},
shutdown: async function (ctx) {
var ci = ctx.layout.controlInterface;
await ctx.controlOut('vendor', 'interface', 0x12, 0x000f, ci);
@@ -342,6 +347,12 @@
await req(0x01, 0x0202);
log('driver', 'FTDI 初始化完成 @' + opts.baudRate + ' bps');
},
/** SET_MODEM_CTRL(0x01):高字节=掩码,低字节=电平(0x01 DTR / 0x02 RTS) */
setSignals: async function (ctx, sig) {
var idx = ctx.layout.controlInterface + 1;
var bits = (sig.dataTerminalReady ? 0x01 : 0) | (sig.requestToSend ? 0x02 : 0);
await ctx.controlOut('vendor', 'device', 0x01, 0x0300 | bits, idx);
},
shutdown: async function (ctx) {
var idx = ctx.layout.controlInterface + 1;
await ctx.controlOut('vendor', 'device', 0x00, 0x0001, idx);
@@ -411,6 +422,11 @@
await ctx.controlOut('class', 'interface', 0x22, 0x0003, 0x0000);
log('driver', 'PL2303 初始化完成 (' + type + ') @' + opts.baudRate + ' bps');
},
/** 标准 CDC 请求 SET_CONTROL_LINE_STATE(0x22):bit0=DTR, bit1=RTS */
setSignals: async function (ctx, sig) {
var bits = (sig.dataTerminalReady ? 0x01 : 0) | (sig.requestToSend ? 0x02 : 0);
await ctx.controlOut('class', 'interface', 0x22, bits, 0x0000);
},
shutdown: async function (ctx) {
var type = ctx.pl2303Type;
if (!type) return;
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ch340-webusb 最小示例</title>
<style>
body { font:14px/1.6 system-ui,sans-serif; margin:0 auto; max-width:640px; padding:16px; }
button { padding:10px 14px; font-size:14px; }
pre { background:#0b0d12; color:#d8dee9; padding:10px; border-radius:8px; height:220px; overflow:auto; }
</style>
</head>
<body>
<h3>ch340-webusb 最小示例</h3>
<p>
<button id="connect">连接 CH340</button>
<button id="close" disabled>断开</button>
<input id="text" value="hello" size="12">
<button id="send" disabled>发送</button>
</p>
<pre id="log"></pre>
<script src="../ch340-webusb.js"></script>
<script>
// 1. 选设备(必须由用户点击触发)
document.getElementById('connect').onclick = async () => {
const port = await TamaCh340.requestPort({ baudRate: 115200 });
// 2. 打开:内部完成 CH340 芯片初始化(波特率 / LCR / DTR-RTS)
await port.open({ baudRate: 115200 });
const writer = port.writable.getWriter();
const reader = port.readable.getReader();
(async () => { // 3. 收
for (;;) {
const { value, done } = await reader.read();
if (done) break;
log('RX ' + Array.from(value).map(b => b.toString(16).padStart(2, '0')).join(' '));
}
})();
document.getElementById('send').onclick = () => // 4. 发
writer.write(new TextEncoder().encode(document.getElementById('text').value + '\r\n'));
document.getElementById('send').disabled = false;
document.getElementById('connect').disabled = true;
document.getElementById('close').disabled = false;
log('已连接 ' + JSON.stringify(port.getInfo()) + ' chip=' + port.chip);
};
document.getElementById('close').onclick = async () => { location.reload(); };
function log(s) { const el = document.getElementById('log'); el.textContent += s + '\n'; el.scrollTop = el.scrollHeight; }
</script>
</body>
</html>
Vendored
+116
View File
@@ -0,0 +1,116 @@
/**
* ch340-webusb 类型声明(UMD:<script> 全局 / ESM / CJS 均可)
*/
export = TamaCh340;
export as namespace TamaCh340;
declare const TamaCh340: TamaCh340.Api;
declare namespace TamaCh340 {
interface OpenOptions {
/** 波特率,默认 460800 */
baudRate?: number;
dataBits?: 8;
stopBits?: 1;
parity?: 'none';
flowControl?: 'none';
/** 单次 bulk 读的字节数,默认 = 端点 wMaxPacketSize(CH340 是 32) */
readTransferSize?: number;
/** 预排队读请求数,默认取驱动的 readQueueDepth(CH340 是 32) */
readQueueDepth?: number;
}
interface Signals {
dataTerminalReady?: boolean;
requestToSend?: boolean;
}
interface Info {
usbVendorId: number;
usbProductId: number;
}
interface Endpoint {
endpointNumber: number;
direction: 'in' | 'out';
type: string;
packetSize: number;
}
interface Layout {
claimedInterfaces: number[];
controlInterface: number;
input: Endpoint;
output: Endpoint;
}
interface Diagnostics {
transfers: number;
bytes: number;
transferSize: number;
}
/** 与 Web Serial 的 SerialPort 对齐,另加驱动相关扩展 */
interface BrowserSerialPort {
open(options?: OpenOptions): Promise<BrowserSerialPort>;
close(): Promise<void>;
getInfo(): Info;
/** open() 之后才有值 */
readonly readable: ReadableStream<Uint8Array> | null;
readonly writable: WritableStream<Uint8Array> | null;
/** 便捷读:timeoutMs 内无数据返回 null(会持有内部 reader) */
read(timeoutMs?: number): Promise<Uint8Array | null>;
/** 便捷写(会持有内部 writer) */
write(data: Uint8Array | ArrayBuffer | number[]): Promise<void>;
setSignals(signals: Signals): Promise<void>;
forget(): Promise<void>;
getDiagnostics(): Diagnostics;
addEventListener(type: 'disconnect', fn: (ev: { type: string; target: BrowserSerialPort }) => void): void;
removeEventListener(type: 'disconnect', fn: (ev: unknown) => void): void;
readonly device: USBDevice;
readonly chip: string;
readonly opened: boolean;
readonly layout?: Layout;
}
interface Driver {
name: string;
readQueueDepth?: number;
filters: USBDeviceFilter[];
supports(device: USBDevice): boolean;
layout(configuration: USBConfiguration): Layout;
initialize(ctx: unknown, options: OpenOptions & { baudRate: number }): Promise<void>;
shutdown?(ctx: unknown): Promise<void>;
setSignals?(ctx: unknown, signals: Signals): Promise<void>;
}
interface Api {
version: string;
/** 调试日志钩子 / 打开 console 输出 */
onLog: ((...args: unknown[]) => void) | null;
debug: boolean;
drivers: Driver[];
UsbSerialPort: new (device: USBDevice, driver: Driver, defaults?: OpenOptions) => BrowserSerialPort;
hasWebUSB(): boolean;
hasWebSerial(): boolean;
isAndroid(): boolean;
/** 全部驱动的 filters(去重) */
supportedFilters(): USBDeviceFilter[];
/** 只筛 CH340/CH341 */
ch34xFilters(): USBDeviceFilter[];
chipName(vendorId: number, productId: number): string;
pickDriver(device: USBDevice): Driver | null;
/** CH34x 波特率分频,与 Linux ch341.c 一致 */
calcCh34xBaud(baudRate: number): { factor: number; divisor: number };
calcFtdiBaud(baudRate: number): { value: number; fractionalIndex: number };
/** 弹设备选择框 → 返回未打开的端口 */
requestPort(options?: OpenOptions & { filters?: USBDeviceFilter[]; allDrivers?: boolean }): Promise<BrowserSerialPort>;
/** 选择 + 打开 */
connect(options?: OpenOptions & { allDrivers?: boolean }): Promise<BrowserSerialPort>;
fromDevice(device: USBDevice, options?: OpenOptions): BrowserSerialPort;
getAuthorizedDevices(options?: { allDrivers?: boolean }): Promise<USBDevice[]>;
getAuthorizedPorts(options?: { allDrivers?: boolean }): Promise<BrowserSerialPort[]>;
}
}
+9 -10
View File
@@ -1,27 +1,26 @@
{
"name": "ch340-webusb",
"version": "1.0.0",
"description": "Browser driver for CH340/CH341 (and CDC-ACM/CP210x/FTDI/PL2303) USB-serial chips over WebUSB — works on Android Chrome, plus a navigator.serial shim.",
"description": "纯前端 CH340/CH341 USB 串口驱动:安卓 Chrome 走 WebUSB 零驱动,桌面走 Web Serial。Browser-only WebUSB driver for CH340/CH341 (plus CDC-ACM/CP210x/FTDI/PL2303) with a navigator.serial shim.",
"main": "ch340-webusb.js",
"types": "index.d.ts",
"files": [
"ch340-webusb.js",
"ch340-serial-shim.js",
"ch340-test.html",
"index.d.ts",
"docs",
"examples",
"README.md",
"LICENSE"
],
"scripts": {
"test": "node test/driver.test.mjs"
},
"keywords": [
"webusb",
"ch340",
"ch341",
"serial",
"android",
"web-serial",
"usb-serial"
],
"keywords": ["webusb", "web-serial", "ch340", "ch341", "usb-serial", "android", "serial", "otg", "cp210x", "ftdi", "pl2303"],
"repository": { "type": "git", "url": "git+https://gitea.truestudio.cn/jiangchen/ch340-webusb.git" },
"homepage": "https://gitea.truestudio.cn/jiangchen/ch340-webusb",
"bugs": { "url": "https://gitea.truestudio.cn/jiangchen/ch340-webusb/issues" },
"engines": { "node": ">=18" },
"license": "MIT"
}
+32
View File
@@ -255,6 +255,38 @@ await t('DTR/RTS 控制(CH340 走 0xA4 取反写入)', async () => {
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, {});