传统的Arduino上位机方案要么依赖Processing写桌面程序,要么用Electron打包一个臃肿的应用,部署和更新都很麻烦。其实在支持Web Serial API的现代浏览器里,一个纯网页就能直接通过串口和Arduino对话。这篇文章会把整套方案拆开讲清楚:浏览器端如何申请串口权限、jQuery如何组织交互逻辑、Arduino端如何解析命令并驱动WS2812灯带。

Web Serial API的工作原理与浏览器限制
Web Serial API是Chromium系浏览器(Chrome、Edge、Opera)提供的原生串口通信接口,它通过JavaScript的navigator.serial对象暴露出来。这个API允许网页直接打开操作系统的串口设备,读取和写入字节数据。它的本质是把原本需要Native应用才能完成的串口操作,搬到了浏览器沙箱里,同时用一套权限机制保证安全。
需要特别注意的是,出于安全考虑,Web Serial API有几个硬性限制。第一,网页必须运行在HTTPS或者localhost环境下,直接双击打开本地HTML文件是无法调用的,通常的做法是起一个简单的本地静态服务器,比如python -m http.server 8000。第二,每次打开串口都需要用户主动触发一个手势事件(比如点击按钮),浏览器会弹出授权窗口让用户选择设备,这一点我们后面写代码时会体现。第三,Firefox和Safari目前不支持该API,如果你的用户群体在这两个浏览器上,需要提前做能力检测并给出降级提示。
API的核心对象有三个:SerialPort代表一个物理串口,通过port.open()可以设置波特率等参数;port.readable是一个ReadableStream,用来接收设备发来的数据;port.writable是一个WritableStream,用来向设备写数据。理解了这三个对象,后面的代码就很好懂了。
网页端实现:串口连接与命令发送
先来看连接部分的代码。我们把串口逻辑封装成一个对象,方便jQuery事件回调中调用。打开串口前要先请求端口,这一步必须在用户的点击事件里完成,否则浏览器会直接拒绝。
// 串口管理对象
var serialMgr = {
port: null,
writer: null,
isOpen: false,
// 请求并打开串口,必须在用户手势中调用
connect: async function (baudRate) {
if (!('serial' in navigator)) {
alert('当前浏览器不支持 Web Serial API,请使用 Chrome 或 Edge');
return false;
}
// 弹出设备选择窗口
this.port = await navigator.serial.requestPort();
await this.port.open({ baudRate: baudRate || 115200 });
// 获取写入流,用于向Arduino发送命令
this.writer = this.port.writable.getWriter();
this.isOpen = true;
return true;
},
// 发送文本命令
send: async function (cmd) {
if (!this.isOpen) return;
var data = new TextEncoder().encode(cmd + '\n');
await this.writer.write(data);
},
// 关闭串口
disconnect: async function () {
if (!this.isOpen) return;
this.writer.releaseLock();
await this.port.close();
this.isOpen = false;
}
};这段代码里有几个细节值得展开。首先,TextEncoder把字符串转成Uint8Array字节流,末尾追加的\n换行符是为了让Arduino端能按行解析命令。其次,getWriter()会对流加锁,所以在关闭串口前必须调用releaseLock()释放,否则close()会一直挂起,这是新手最容易踩的坑之一。
除了发送,接收Arduino上传的数据同样重要,比如读取板子上温度传感器的值。接收部分用port.readable.getReader()循环读取,由于串口数据是分片到达的,需要自己拼缓冲区按换行符切分。篇幅所限这里不展开完整实现,思路是维护一个字符串缓冲区,每次读取后追加,再按\n分割出完整行即可。
用jQuery搭建LED控制界面
界面部分我们做三块功能:颜色选择、亮度滑块、模式切换。用jQuery的好处是事件绑定和DOM更新非常简洁,尤其是处理滑块拖动这种高频事件时,配合节流可以避免串口被写爆。
下面是页面结构,用了原生的<input type="color">拾色器和<input type="range">滑块,样式简单但功能齐全:
<div class="panel">
<button id="btnConnect">连接Arduino</button>
<button id="btnDisconnect" disabled>断开连接</button>
<hr/>
<label>颜色:<input type="color" id="colorPicker" value="#ff0000"/></label>
<label>亮度:<input type="range" id="brightness" min="0" max="255" value="120"/></label>
<label>模式:
<select id="mode">
<option value="static">常亮</option>
<option value="rainbow">彩虹流动</option>
<option value="breath">呼吸灯</option>
</select>
</label>
<p id="status">未连接</p>
</div>接着是jQuery的事件逻辑。颜色变化时把十六进制色值转换成RGB三元组发给Arduino;亮度滑块用节流函数限制发送频率,否则拖动时会瞬间产生上百条串口写入,Arduino的接收缓冲区可能来不及处理导致命令丢失;模式切换直接发送模式名称。整体代码如下:
$(function () {
var throttleTimer = null;
function sendThrottled(cmd) {
clearTimeout(throttleTimer);
throttleTimer = setTimeout(function () {
serialMgr.send(cmd);
}, 80);
}
// 连接按钮
$('#btnConnect').on('click', async function () {
var ok = await serialMgr.connect(115200);
if (ok) {
$('#status').text('已连接');
$('#btnConnect').prop('disabled', true);
$('#btnDisconnect').prop('disabled', false);
}
});
// 断开按钮
$('#btnDisconnect').on('click', async function () {
await serialMgr.disconnect();
$('#status').text('未连接');
$('#btnConnect').prop('disabled', false);
$('#btnDisconnect').prop('disabled', true);
});
// 颜色选择:十六进制转RGB后发送
$('#colorPicker').on('change', function () {
var hex = $(this).val();
var r = parseInt(hex.substr(1, 2), 16);
var g = parseInt(hex.substr(3, 2), 16);
var b = parseInt(hex.substr(5, 2), 16);
serialMgr.send('COLOR,' + r + ',' + g + ',' + b);
});
// 亮度滑块:节流发送
$('#brightness').on('input', function () {
sendThrottled('BRIGHT,' + $(this).val());
});
// 模式切换
$('#mode').on('change', function () {
serialMgr.send('MODE,' + $(this).val());
});
});命令格式采用关键字,参数1,参数2...的CSV风格,解析简单,调试时用串口监视器也容易看清。如果以后要扩展更多指令,这种格式比JSON省流量,对Arduino这种内存紧张的设备更友好。
Arduino端:命令解析与WS2812驱动
硬件部分以最常见的WS2812B灯带为例,它只需要一根数据线,配合Adafruit的NeoPixel库就能驱动。Arduino端要做的事情是:初始化串口、逐行读取网页发来的命令、按逗号分割后执行对应操作。
Arduino的Serial.read()每次只读一个字节,所以要自己维护一个字符缓冲区,遇到换行符就认为一条命令结束,再去解析。这就是为什么前面网页端每条命令都要追加\n的原因。完整代码如下:
#include <Adafruit_NeoPixel.h>
#define LED_PIN 6 // 灯带数据引脚
#define LED_COUNT 30 // 灯珠数量
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
char buf[64]; // 命令缓冲区
uint8_t idx = 0;
uint8_t brightness = 120;
void setup() {
Serial.begin(115200);
strip.begin();
strip.setBrightness(brightness);
strip.show(); // 初始全灭
}
void loop() {
// 逐字节读取,按行分割命令
while (Serial.available() > 0) {
char c = Serial.read();
if (c == '\n' || c == '\r') {
if (idx > 0) {
buf[idx] = '\0';
handleCommand(buf);
idx = 0;
}
} else if (idx < sizeof(buf) - 1) {
buf[idx++] = c;
}
}
runAnimation();
}
// 解析并执行命令
void handleCommand(char *cmd) {
if (strncmp(cmd, "COLOR,", 6) == 0) {
int r, g, b;
if (sscanf(cmd + 6, "%d,%d,%d", &r, &g, &b) == 3) {
colorWipe(strip.Color(r, g, b));
}
} else if (strncmp(cmd, "BRIGHT,", 7) == 0) {
brightness = atoi(cmd + 7);
strip.setBrightness(brightness);
strip.show();
} else if (strncmp(cmd, "MODE,", 5) == 0) {
setMode(cmd + 5);
}
}
// 简单的填充函数
void colorWipe(uint32_t color) {
for (int i = 0; i < LED_COUNT; i++) {
strip.setPixelColor(i, color);
}
strip.show();
}这里有几个实践建议。缓冲区大小要根据最长命令来定,64字节对本文的命令绰绰有余;解析时用strncmp匹配前缀比String对象更省内存,避免堆碎片。另外要注意,NeoPixel的show()在灯珠数量多时会阻塞几百毫秒,如果灯带很长,建议把动画逻辑放到计时器里分帧刷新,避免影响串口接收的实时性。
接线方面,WS2812B灯带的DIN接Arduino的D6,5V供电建议用外部电源,灯珠超过20颗时不要直接用开发板的5V引脚供电,否则电压不稳会出现灯珠闪烁或颜色错乱,外部电源记得与Arduino共地。
调试技巧与常见问题
整个链路涉及浏览器、串口、Arduino三层,出问题时定位起来容易混乱,建议分段排查。先用Arduino IDE自带的串口监视器直接发送COLOR,0,255,0验证板端代码,确认灯带能正常变色后,再去调网页端。网页端如果调用requestPort()报错,先检查页面是不是HTTPS或localhost,再确认没有其他程序占用了串口——串口是独占资源,串口监视器开着的时候浏览器是打不开的。
另一个常见问题是页面刷新后串口断开。浏览器在页面卸载时会自动释放串口,但可以用navigator.serial.getPorts()拿到之前授权过的端口,实现刷新后一键重连,不需要用户再次选择设备,体验会好很多。如果项目要长期运行,还可以监听port.disconnect事件,在USB被拔出时及时更新界面状态,给用户明确提示。
这套方案跑通之后,可以玩的扩展方向很多,比如加上WebSocket做多人同时控制、接入音乐频谱做声控灯效、或者把控制面板做成一个手机可访问的局域网页面。Web Serial API把硬件开发的门槛拉到了会写网页就能上手的高度,非常适合快速做原型。
Web Serial APIjQueryArduino修改时间:2026-09-04 10:29:32