前面把数据采出来了、把事件检出来了,这一篇解决"怎么让人一眼看懂"。
1. 为什么要单独做一块仪表盘
第 4 篇的原型已经能用串口打印 CSV、用 afplay 播放提示音。但演示和测评文章都需要一个"看得见"的东西:
一条正在跳动的 var(phase) 曲线
一个足够大的「有人 / 无人」状态
事件发生的红色区间
检出次数、采样率这些可量化指标
最初想用 matplotlib 的实时窗口,但有两个现实问题:
这台 Mac 的 Python 没有 tkinter,matplotlib 的桌面后端不可靠
桌面窗口不方便截图、录屏和远程查看
所以改成 Web 仪表盘:后端用 Flask 提供数据,前端用浏览器 Canvas 画图。不需要装 GUI 工具包,能直接全屏录像,手机连同一个局域网也能看。
2. 整体结构
串口 CSV ──► 读取线程 ──► 环形缓冲 + 状态 ──► Flask /api/data ──► 浏览器轮询(200 ms) (LM20) (pyserial) (deque 400) (JSON) ├── Canvas 曲线 ├── 大号状态 └── 语音提示
【图片1:仪表盘数据通路】
分工很明确:
读取线程只做三件事:读串口、解析 CSV、更新缓冲和计数
Flask 只暴露一个 JSON 接口和几个静态路由
前端 只负责轮询、画图和放声音
3. 后端:tools/cs_dashboard.py
3.1 读取线程
with serial.Serial(port, baud, timeout=1) as ser:
while True:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if not line.startswith("CSV,"):
continue
parts = line.split(",")
if len(parts) != 10:
continue
try:
nums = [float(x) for x in parts[1:10]]
except ValueError:
continue # 固件日志与 CSV 交错时,跳过被污染的行解析后把样本放进固定长度 deque(maxlen=400),同时更新运动状态和检出次数。断连时线程会每 2 秒重试,不会让整个服务退出。
3.2 JSON 接口
@app.get("/api/data")
def api_data():
with _lock:
samples = [{k: _finite(v) for k, v in s.items()} for s in _samples]
return jsonify({
"samples": samples,
"motion": _state["motion"],
"events": _state["events"],
"rate": _state["rate"],
"count": _state["count"],
"connected": _state["connected"],
"threshold": THRESHOLD,
})关键坑:NaN 不是合法 JSON。
CS 在信号质量差时会输出 NaN。Python 的 json 模块会把它原样写成 NaN,curl 看着没问题,但浏览器的 response.json() 会直接抛异常,页面就显示"仪表盘服务未响应"。修复只需要在序列化前把非有限值转成 null:
def _finite(value): if isinstance(value, float) and not math.isfinite(value): return None return value
前端也要相应地把 isFinite 改成 Number.isFinite,否则 null 会被当成 0 画进曲线。
3.3 其他路由
| / | 返回 dashboard.html |
| /api/data | 实时样本与状态 |
| /audio/alert.mp3 | edge-tts 生成的语音 |
| /favicon.ico | 返回 204,避免无意义的 404 |
4. 前端:tools/dashboard.html
4.1 轮询与状态
setInterval(poll, 200);
async function poll() {
const res = await fetch('/api/data', { cache: 'no-store' });
const data = await res.json();
...
}200 ms 的轮询对应 5 Hz 刷新,足够跟上 10 Hz 的 CS 数据,又不会让浏览器太忙。
状态只有三种:
灰色「等待链路…」
绿色「无人 / 链路稳定」
红色「有人 / 检测到运动」
4.2 两张 Canvas
上图:IFFT / Phase Slope / RTT 三条距离曲线,y 轴自动缩放
下图:var(phase) 填充曲线、0.10 阈值虚线、运动区间红色背景
所有绘制都用原生 Canvas 2D,没有引入 Chart.js 之类的依赖,断网也能跑。
4.3 浏览器放声音
浏览器的自动播放策略要求先有一次用户交互。所以界面右下角有一个「启用声音」按钮:
soundBtn.addEventListener('click', () => {
audio.play().then(() => {
audio.pause(); audio.currentTime = 0; // 解锁后立刻暂停
soundOn = true;
});
});之后每次 motion 由 0 变 1,就播放一次语音,并带冷却时间。
5. 界面效果
【图片2:Web 实时仪表盘】
截图时的状态:
| 状态 | 有人 / 检测到运动 |
| 采样率 | 9.9 样本/秒 |
| 累计样本 | 203 |
| 检出次数 | 5 |
| 距离曲线 | IFFT / Phase / RTT 实时更新 |
| 抖动曲线 | 两个明显峰值,红色背景对应运动区间 |
截图用的就是第 4 篇那套 LM20 + XIAO 的 2.3 m 链路。人坐在两板之间,起身、走开、再回来,状态和曲线都会跟着变。
6. 踩坑记录
| 页面显示"仪表盘服务未响应" | 固件输出 NaN,浏览器 JSON 解析失败 | 服务端转 null,前端用 Number.isFinite |
| 曲线出现 0 值尖刺 | 全局 isFinite(null) 返回 true | 改用 Number.isFinite(null) |
| 点了没声音 | 浏览器自动播放限制 | 「启用声音」按钮先解锁 |
| 串口打不开 | cs_alert.py / capture_serial.py 同时占用 | 同一时间只跑一个串口程序 |
| 控制台 404 | 浏览器请求 favicon | 增加 /favicon.ico 返回 204 |
7. 使用方法
# 依赖(已在 .venv 中) .venv/bin/pip install flask pyserial # 启动,可选保存 CSV .venv/bin/python tools/cs_dashboard.py --save logs/dashboard.csv # 浏览器打开 open http://127.0.0.1:8765
接口也可以单独调用:
curl -s http://127.0.0.1:8765/api/data | \ python3 -c "import sys,json; d=json.load(sys.stdin); print(d['count'], d['rate'], d['motion'])"
8. 可以继续做的
用 SSE / WebSocket 替代轮询,进一步降延迟
加历史回放:把 dashboard.csv 拖进页面重放
手机适配:改一下 Canvas 高度,就能在手机上全屏看
录像:直接 OBS 捕获浏览器窗口,适合做演示视频
9. 小结
这一篇把第 4 篇的 CSV 变成了可看、可听、可录的仪表盘:
后端一个读取线程 + 一个 JSON 接口
前端两张 Canvas + 一个状态灯 + 一个声音按钮
最难的不是画图,而是处理 NaN 和浏览器自动播放这类"看起来无关"的细节
附:本系列回顾
| 1 | 开箱与环境 | 远程编译 + 本地烧录链路 |
| 2 | BLE 基础 | 广播、连接、NUS |
| 3 | CS 入门 | 双板测距与误差分析 |
| 4 | 环境心跳 | 用链路抖动感知环境 |
| 5 | 实时可视化 | Flask + Canvas 仪表盘 |
我要赚赏金
