一、需求:台灯配不上手机,像话吗
前三篇做完,这台灯已经有模有样了:旋钮调亮度、按键切模式、彩虹自动流转。但有一个说不出口的尴尬——想换个颜色,得拧旋钮慢慢找。都什么年代了,调色这事就该在手机上戳两下。
所以 M3 的需求清单:
网页中间一个圆形调色盘,点哪是哪
下面一排常用颜色,一键直达
点色盘或颜色,板子上的 RGB LED 实时同步
一根亮度滑动条
通信方式我说了算——串口就挺好:板子上自带 MCP2221A USB 转串口,Mac 上零成本,9600 波特率点一下才传 10 个字节,延迟 10 毫秒级,完全跟手
【配图 1】最终效果三连:手机选红色/琥珀/青,板子 RGB 即时同步(亮度 56%)



二、总体架构:四层链路,各管一段
┌──────────┐ HTTP POST ┌──────────────┐ 串口 9600 ┌────────────┐ │ 手机/电脑 │ ─────────────→ │ Mac 本地服务器 │ ─────────────→ │ dsPIC33A │ │ H5 调色台 │ /set?r=&g=&b= │ Python 标准库 │ "C r g b\n" │ UART 解析 │ └──────────┘ └──────────────┘ │ → PWM 驱动 │ └────────────┘
【配图 2】系统架构图:Mac 侧两跳(网页→服务器),板子侧两跳(UART 解析→PWM 渲染)

分层的好处是每一层都能单独调试:网页写错了用浏览器控制台看;服务器转发错了用 curl 打;串口通不通用 cat 设备文件看输出;固件解析错了用任意串口工具手动发一条命令——这就是选文本协议的原因,下面细说。
三、通信协议:土办法有时是最好的办法
协议就一条指令:
C 128 0 255\n │ │ │ └─ 蓝 0~255 │ │ └───── 绿 0~255 │ └──────── 红 0~255 └─────────── 命令字:Color
选它的理由,事后看都成立:
人能读、人能敲。调试的时候打开任何串口工具,手打 C 255 0 0 回车,灯就红——不需要写专用上位机。第 2 篇的教训还在:让程序自己告诉你它看到的世界,文本协议就是这个哲学的延伸
固件解析零依赖。不写 scanf(嵌入式里又重又危险),十几行手动解析:逐字符转数字、空格分隔、超 255 截断、攒到 \n 才算一条。完整代码:
/* 解析 "C r g b"(r/g/b 十进制 0~255),成功返回 true */
static bool parseColorCommand(const char *line, uint8_t *r, uint8_t *g, uint8_t *b)
{
if (line[0] != 'C' || line[1] != ' ') {
return false;
}
const char *p = line + 2;
uint32_t vals[3] = {0, 0, 0};
for (uint8_t i = 0; i < 3; i++) {
uint32_t v = 0;
bool hasDigit = false;
while (*p == ' ') { p++; }
while (*p >= '0' && *p <= '9') {
v = v * 10u + (uint32_t)(*p - '0');
if (v > 255u) { v = 255u; } /* 防御:越界截断 */
hasDigit = true;
p++;
}
if (!hasDigit) { return false; }
vals[i] = v;
}
*r = (uint8_t)vals[0]; *g = (uint8_t)vals[1]; *b = (uint8_t)vals[2];
return true;
}接收侧用一个行缓冲状态机:中断太麻烦,直接主循环里轮询 UART1_IsRxReady(),攒够一行处理一次,顺手做了超长丢弃防垃圾数据占满缓冲区。
四、固件:加一个"远程模式"
模式机原来有白光/彩色两档,现在加第三档 REMOTE:收到任何合法颜色命令就切进去,颜色完全由串口说了算;按一下 S1 切回白光本地模式。状态指示也顺便升级:
LED0 亮 = 彩色模式(原逻辑)
LED1 亮 = 远程模式(新增,一眼看出灯被谁控制)
这个设计的用意是控制权永远有明确的归属:本地旋钮不会和网页抢——你在网页上点了颜色,旋钮拧到天上去灯也不理你,直到你按 S1 把控制权拿回来。两种控制源互不打架,这比"谁最后一个动谁赢"的模糊逻辑好调试得多。
顺带一提,这版固件上电会往串口发一句 LAMP READY——一是给串口工具一个"连上了"的提示,二是顺手验证了 TX 通路(MCP2221A 是双向的,别浪费)。
固件侧的接收任务,就是第 3 篇那个"行缓冲状态机"的 UART 版——轮询、攒行、回车结算:
/* UART 接收任务:攒行,收到完整颜色命令返回 true */
static bool uartTask(uint8_t *r, uint8_t *g, uint8_t *b)
{
static char line[UART_LINE_MAX];
static uint8_t len = 0;
while (UART1_IsRxReady()) {
char c = (char)UART1_Read();
if (c == '\n' || c == '\r') {
if (len > 0) {
line[len] = '\0';
len = 0;
return parseColorCommand(line, r, g, b);
}
} else if (len < UART_LINE_MAX - 1) {
line[len++] = c;
} else {
len = 0; /* 超长丢弃,防垃圾数据占满缓冲区 */
}
}
return false;
}主循环里的集成也就三行——收到命令进远程模式,S1 切回本地,控制权永远单一:
if (uartTask(&remoteR, &remoteG, &remoteB)) { /* 收到串口颜色命令 */
mode = MODE_REMOTE; /* 网页接管 */
LATCbits.LATC4 = 1; /* LED1 = 远程模式指示 */
}
/* S1 按下:任何模式 -> 白光(旋钮夺回控制权) */五、网页:一个文件写完全部前端
整个前端就一个 index.html,原生 Canvas + Pointer Events,没引任何库——台灯控制台要什么框架。
色盘绘制是核心,HSV 色轮的本质就一句话:角度决定色相,半径决定饱和度:
var hue = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360; var sat = Math.min(1, dist / R);
逐像素填进 ImageData:圆心白(饱和度 0),外沿艳(饱和度 1),绕一圈 360° 彩虹。交互用 Pointer Events 而不是分别写 touch/mouse——一套代码通吃鼠标和触屏,手机拖动选色跟手顺滑。
色轮的交互核心:Pointer Events 一套代码通吃鼠标和触屏,sendThrottled 把拖动限流在 25 次/秒(9600 波特打得起,灯也跟手):
function pickColor(clientX, clientY) {
var rect = canvas.getBoundingClientRect();
var x = clientX - rect.left - rect.width / 2;
var y = clientY - rect.top - rect.height / 2;
var dist = Math.sqrt(x * x + y * y);
if (dist > rect.width / 2) return; // 点在圆外忽略
var hue = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
var sat = Math.min(1, dist / (rect.width / 2)); // 角度=色相,半径=饱和度
var rgb = hsvToRgb(hue, sat, 1);
state.r = rgb[0]; state.g = rgb[1]; state.b = rgb[2];
sendThrottled(); // 40ms 节流后发 POST /set
}
canvas.addEventListener('pointerdown', e => { dragging = true; pickColor(e.clientX, e.clientY); });
canvas.addEventListener('pointermove', e => { if (dragging) pickColor(e.clientX, e.clientY); });亮度条的实现更偷懒但有效:网页端把亮度和颜色相乘之后再下发。板子永远只收到最终 RGB,不需要知道"亮度"这个概念——状态越简单,出错的地方越少。
服务器是 50 行 Python,全是标准库:HTTP 服务绑 0.0.0.0(手机才能访问),收到 POST /set?r=&g=&b= 就拼一条 C r g b\n 写串口。串口配置直接用 termios 模块设 9600 8-N-1,连 stty 都不用敲。
服务器核心就两个方法:收到 POST 就拼串口命令,串口配置直接用 termios(连 stty 都省了):
def send_color(r: int, g: int, b: int) -> bool:
cmd = f"C {r} {g} {b}\n".encode("ascii")
with _serial_lock:
return os.write(_serial_fd, cmd) > 0
class Handler(BaseHTTPRequestHandler):
def do_POST(self): # 网页调色台打过来的请求
q = parse_qs(urlparse(self.path).query)
r = max(0, min(255, int(q["r"][0]))) # 越界钳位,不信任任何输入
g = max(0, min(255, int(q["g"][0])))
b = max(0, min(255, int(q["b"][0])))
ok = send_color(r, g, b)
self._respond(200 if ok else 503, b"ok")六、踩坑实录:色盘为什么缺了个角
功能做完,手机上一看——色盘渲染成了个"缺角泪滴",下面还拖着一大圈残影:
【配图 3】Bug 现象实拍:色盘缺角成"泪滴"

原因是个经典的 Canvas 高分屏陷阱。为了 Retina 屏上的清晰度,我把画布 backing store 设成了 CSS 尺寸的 2 倍,打算用坐标变换放大绘制。但 putImageData() 不吃 setTransform 那一套——它永远按原始设备像素写入。结果就是:我算的像素数据只填满了缓冲的四分之一,显示出来缺角又带残影。
修复就一条路:从头到尾都在设备像素坐标系里干活,放弃变换矩阵:
canvas.width = Math.round(css * dpr); // backing store = 设备像素 // 逐像素计算时,R、dx、dy 全部用 canvas.width 这套设备像素 ctx.putImageData(img, 0, 0); // 不再依赖任何变换
教训通用:用了 putImageData,变换矩阵就是摆设。要么全程设备像素(性能还好),要么改用 drawImage 走变换管线。
【配图 4】修复后的手机网页截图:完整圆形色盘 + 亮度条 + 常用颜色

七、效果与小结
现在这台灯有三重身份:
| 旋钮 + 按键 | 本地随手调,断电也能用 |
| 彩虹自动流转 | 氛围模式,当背景灯 |
| 手机网页调色台 | 要精确颜色的时候,躺床上调 |
回头看,这一篇的技术密度其实不高——没有寄存器、没有滤波算法,难点全在"把四层链路打通":每一层单独都简单,串起来不出错靠的是文本协议带来的可观测性。做嵌入式网关类项目时,"人能读懂的协议 + 每层可单独调试"永远是最省时间的路线。
我要赚赏金
