RV1106 陪伴玩具:设备配对的设计与实现
0. 结论
配对 = 板子请求码 → 板子显示码 → 买家输入 → 板子取 token → 拉配置,五段。
但真正让这套东西稳定的,不是这五段,而是后面的三条约束:
- 状态只在服务端:in_stock / activated / paired / disabled,板子和页面都不再自己猜;
- 每个板端请求都重新查库:token 只是“证明我是哪台设备”,能不能用要看设备当前状态;
- 凭证可作废:token 带 ver,解绑/删除时 token_version+1,旧 token 永久 401。
flowchart LR
A["板子请求码"] --> B["板子显示 6 位码"]
B --> C["买家网页输入"]
C --> D["板子轮询取 token"]
D --> E["拉天气 / 语音配置"]
E --> F["板端 /board/state 确认 paired"]1. 为什么不用「网页生成码 → 板子输入」
最初是反过来的:买家在网页生成 8 位码,再在板子屏幕上用软键盘输入。
问题不在功能,在 输入成本:
| 项 | 网页生成、板子输入 | 板子显示、网页输入 |
|---|---|---|
| 板端操作 | 调键盘、逐字符输入 8 位 | 无 |
| 输错概率 | 高(小屏 + 软键盘) | 低(手机/电脑键盘) |
| 有效期 | 30 分钟(来回切) | 5 分钟(够用且更安全) |
| 用户困惑 | 「我该在哪输?」 | 屏幕直接显示码 |
结论:把输入放在输入体验更好的一端。
2. 先定状态机:唯一真相在服务端
数据库只存原始字段,不存“页面状态”:
| 字段 | 含义 |
|---|---|
| devices.status | in_stock / active / disabled |
| devices.buyer_id | 绑定买家;为空 = 在库 |
| devices.paired_at | 板子最后一次成功取走 token 的时间 |
| devices.token_version | 凭证版本;解绑/删除时 +1 |
对外统一折算成 state:
| state | 条件 | 商家页 | 买家页 | 板端 |
|---|---|---|---|---|
| in_stock | 无买家 | 在库 | 未激活 | 未配对 |
| activated | 有买家、未配对 | 已激活 · 待配对 | 已激活,等配对 | 未配对 |
| paired | 有买家、paired_at 非空 | 已配对 | 已配对 | 已配对 |
| disabled | status=disabled | 已停用 | 已停用(不是“无设备”) | 已停用 |
stateDiagram-v2
[*] --> in_stock: 商家入库
in_stock --> activated: 买家激活 / 绑定 buyer_id
activated --> paired: 板端取走 token / 写 paired_at
in_stock --> disabled: 商家停用
activated --> disabled: 商家停用
paired --> disabled: 商家停用
disabled --> in_stock: 启用(无买家)
disabled --> activated: 启用(有买家 / 未配对)
disabled --> paired: 启用(有买家 / 已配对)
paired --> in_stock: 商家解绑 / token_version+1
activated --> in_stock: 商家解绑
in_stock --> [*]: 删除 / 留墓碑关键取舍:停用不递增 token_version。
如果停用就换版本,商家重新启用后板子必须重新配对才能恢复;但产品要的是“停用即时 403,启用自动恢复”。所以停用靠每次请求查库生效,版本只在解绑/删除/换主时递增。
3. 五段式配对
① 板子请求码
POST /api/v1/board/pair/request
{"sn": "MG9460E7B5"}
→ {"session": "9128...", "code": "772955", "expires_in": 300}- 已入库设备直接出码;未登记 SN 首次联网自动登记(in_stock),商家后台立刻能看到;
- 已删除的 SN 有墓碑,
pair/request返回 403,板子不能把商家删掉的设备复活; - 6 位数字,字符集
23456789,避开 0/1; - 每次请求新建会话,旧会话各自 TTL,互不覆盖。
② 板子显示码
屏幕「板端配置 → 设备配对」触发 pair-tv.sh:
cfg-fetch -p "$SN" -t /userdata/device.token -c /tmp/pair.codecfg-fetch 用 OpenSSL 直连 HTTPS,把 <code> <session> 写进 /tmp/pair.code;
UI 的 800ms 定时器只读文件,把码显示在按钮上,UI 线程不做网络。
二维码只编码这 6 位数字,不再编码一个打不开的 /p?c= 页面。
③ 买家确认
买家在 /account「我的设备」输入 6 位码:
POST /api/v1/me/device/pair
{"code": "772955"}
→ {"ok": true, "sn": "MG9460E7B5", "message": "已发送给设备"}服务端校验五件事:
- 会话存在、未被取过、未过期;
- 设备属于当前买家(防止拿别人设备的码绑定);
- 设备当前是 active,不能是 disabled;
- 买家确实已经激活了这台设备(activated 状态);
- 通过后才签发带
ver的 device token,挂到会话上。
④ 板子取 token
板子每 3 秒轮询:
GET /api/v1/board/pair/{session}
→ {"status": "pending"}
→ {"status": "paired", "token": "..."} // 取走即失效取走时服务端做两件事:写 paired_at、清空会话里的 token。二次轮询不会再拿到同一个 token。
⑤ 写 token、确认状态、拉配置
写 /userdata/device.token (0600)
→ GET /api/v1/board/state 确认 state=paired
→ GET /api/v1/board/config 拉天气 / 语音凭据
→ 写 /userdata/weather.env、/userdata/bailian/creds.env
→ 语音 Key 变化才重启 bailian-csequenceDiagram
autonumber
participant B as 板端 LVGL
participant S as FastAPI
participant U as 买家网页
B->>S: POST /board/pair/request (SN)
S->>S: 查设备 / 自动登记 / 墓碑拒绝
S-->>B: session + code + 300s
B->>B: 显示 6 位码 + 二维码
U->>S: POST /me/device/pair (code)
S->>S: 校验会话 / 归属 / 设备 active
S-->>U: 已发送给设备
loop 每 3 秒
B->>S: GET /board/pair/{session}
S-->>B: pending
end
B->>S: GET /board/pair/{session}
S-->>B: paired + token
B->>B: 写 device.token,state=paired
B->>S: GET /board/state
S-->>B: state=paired
B->>S: GET /board/config
S-->>B: 天气 / 语音配置4. 每个 /board/* 请求怎么校验
token 只证明“我是哪台设备”,不能证明我现在还能用。所以每个带 X-Device-Token 的请求都走同一条校验链:
flowchart TD
A["/api/v1/board/* 请求"] --> B{"token 有效?"}
B -- 否 --> E1["401 设备 token 无效"]
B -- 是 --> C{"设备行还存在?"}
C -- 否 --> E1
C -- 是 --> D{"status = disabled?"}
D -- 是 --> E2["403 设备已停用"]
D -- 否 --> F{"token 版本匹配?"}
F -- 否 --> E3["401 凭证已失效,请重新配对"]
F -- 是 --> G["放行,touch last_seen"]对应代码(简化):
def _require_device(token):
payload = verify_device_token(token) # 401
device = db.get_device(payload["sub"]) # 401
if device["status"] == "disabled": # 403
raise HTTPException(403, "设备已停用")
if payload.get("ver", 0) != device["token_version"]: # 401
raise HTTPException(401, "设备凭证已失效,请重新配对")
db.touch_device(device["sn"])
return device例外:/board/pair/request 用 SN 换码,不需要 token;/board/pair/{session} 用会话 id,也不带 token,但会检查设备是否停用。
5. 板端屏幕不再猜状态
改造前:板端只看 /userdata/device.token 是否存在,存在就显示“已配对”。
这会导致商家停用后,板子仍然显示“已配对”,而云端其实已经 403。
现在板端只认服务端给的 /board/state:
# /userdata/bin/device-state.sh(60 秒一次,也可手动跑)
cfg-fetch -t /userdata/device.token -S /tmp/device.statecfg-fetch -S 把 state 写成 paired / in_stock / activated / unpaired / disabled;
UI 每秒读这个文件,映射关系:
flowchart LR
S["服务端 state"] --> F["/tmp/state"]
F --> M{"板端映射"}
M -->|paired| H["隐藏状态标"]
M -->|disabled| D["显示 已停用"]
M -->|in_stock / activated / unpaired| U["显示 未配对"]三个细节:
in_stock/activated也显示“未配对”,因为设备还没有真正配对;- token 文件存在但 state 是 unpaired(例如 401),仍然显示未配对,不再被本地文件骗;
- 配对页也读同一个状态文件;停用状态下点配对,码/结果仍然能显示,只是服务端会拒绝。
6. 停用、启用、解绑、删除
| 动作 | 数据库 | 板端 | 买家页 |
|---|---|---|---|
| 停用 | status=disabled,保留 buyer/paired_at | 每个请求 403,屏幕“已停用” | 看到“已停用”,不是“无设备” |
| 启用 | status=active 或 in_stock | 同一 token 恢复,不必重配 | 恢复原状态 |
| 解绑 | buyer_id/paired_at 清空,token_version+1 | 旧 token 401,屏幕“未配对” | 设备消失,可激活另一台 |
| 删除 | 仅未绑定;写墓碑;删除行 | 旧 token 401;SN 不能被自动复活 | 设备消失 |
停用和启用是“开关”,解绑和删除是“换主/作废”。这两个语义不能混。
7. 兼容升级前的老设备
老 token 没有 ver 字段,按 0 处理;老设备的 token_version 默认也是 0,所以升级后仍然可用。
只有发生下面这些动作,版本才会变:
- 解绑 → +1;
- 删除/换主 → +1;
- 商家手动恢复已删除设备 → 分配一个更高的新版本。
迁移脚本只做两件加法:给 devices 加 token_version,建 device_tombstones 表;不改任何已有行。
8. 真机踩过的坑(更新)
8.1 token 可以重复取
第一版消费 token 只写 used_at,没清空 token 字段,轮询会反复返回同一个 token。
修复:消费时 UPDATE pair_sessions SET used_at=?, token=NULL。
8.2 网页不知道板子成没成
买家点完“发给板子”后页面一直显示“已发送”。
补 GET /api/v1/me/device/pair/status?code=,页面 2 秒轮询,板子取走 token 后显示“已完成 ✓”。
8.3 刷机即失联
/userdata 被清空 → token 没了。开机脚本优先用 device.token,没有才回退 bootstrap;两者都没有就停在“未配对”。
8.4 未配对时界面卡死
天气页/音色页在未配对时仍去请求云端,UI 线程被网络锁阻塞。
修复两层:页面入口先查状态,没有 token 只显示“未配对,无法使用”;网络层读接口全部 pthread_mutex_trylock,抢不到锁就跳过本帧。
8.5 崩溃后屏幕停在残帧
看门狗重启界面时调用改名前的老脚本 S45lvgl,进程退出后永远拉不起来。
修复:看门狗改调 S96lvgl start,并新增“进程不存在就立刻重启”。
8.6 V55 配对 403:SN 回落到已删除的 MG0001
这是最近一次真机问题,值得单独记:
- 现象:点“设备配对”,
/tmp/pair.out写请求配对码失败 HTTP 403,屏幕不出码; - 根因:V55 的
pair-tv.sh是SN="${FACE_SN:-$(cat /tmp/face-sn 2>/dev/null || echo MG0001)}"; 板子没有face.env,/tmp/face-sn也不存在,于是回落到 MG0001; 而 MG0001 刚被商家删除(墓碑),pair/request对已删 SN 返回 403; - 修复:装 V56 脚本(用
/usr/bin/dev-sn),并写/userdata/face/face.env(SN=MG9460E7B5,0600); - 教训:SN 永远从设备自身取,不要从旧配置兜底;删除留墓碑是对的,否则删掉的设备会被板子自动复活。
8.7 in_stock 被错误隐藏
第一版 UI 只认 paired / unpaired / disabled,服务端返回 in_stock 时落进了“隐藏”分支,屏幕不显示“未配对”。
修复:in_stock / activated 也映射为“未配对”;paired 才隐藏。
9. 二手转让
当前是商家售后解绑,不是买家一键转让:
sequenceDiagram
autonumber
participant O as 旧买家
participant M as 商家后台
participant S as 服务端
participant N as 新买家
participant B as 板端
O->>M: 申请转让 / 售后
M->>S: POST /admin/api/devices/{sn}/unbind
S->>S: 清 buyer/paired,token_version+1,清人脸
S-->>B: 下一次请求 401
B->>B: state=unpaired,显示未配对
M->>S: 生成新激活码
N->>S: 注册 / 登录 / 激活
N->>B: 板端出码,新买家输入
B->>S: 轮询取新 token(新 ver)
B->>S: /board/state → paired- 旧 token 永久失效,旧买家账号保留,可激活另一台;
- 旧人脸绑定清掉(FRS 云端尽力删除);
- 家庭关系属于买家账号,解绑设备不会自动把旧买家移出家庭;
- 买家侧没有“自助解绑/转让”入口——对毕业设计/小批量,商家审批是更低风险的做法。
10. 安全边界
| 风险 | 对策 |
|---|---|
| 猜 6 位码 | 5 分钟 TTL + 一次性 + 会话 id 不可枚举 |
| 拿别人设备的码绑定 | 校验设备 buyer_id = 当前买家 |
| 停用后继续用 | 每个 /board/* 请求查库,disabled 直接 403 |
| 解绑后旧 token 继续用 | token 带 ver,token_version+1 后旧 token 401 |
| 删除设备被自动复活 | device_tombstones 墓碑,pair/request 对已删 SN 403 |
| token 泄露 | 只在配对链路上传输一次,板端 0600 落盘 |
| 重放 | 会话取走即 token=NULL,二次轮询不再返回 |
11. 验证与一句话总结
已验证:
- pytest 9 条(状态机 / 停用 403 / 启用恢复 / 解绑 401 / 删除墓碑 / 二手转让 / 老 token 兼容);
- 真实 HTTPS 交叉验证 24 项;
- 真机(RV1106,V55):配对成功、停用屏幕“已停用” + /config 403、启用恢复 paired + 200。
一句话:
把“输入”放在体验好的一端,把“状态”放在服务端,把“凭证”做成可作废的。
板子不需要键盘,买家不需要懂命令行,服务端不需要信任任何未经验证的声明。
环境:Luckfox Pico Ultra W(RV1106,EMMC,Buildroot)+ FastAPI/SQLite + LVGL 8.3
相关文章
评论
评论区由 giscus 提供(基于 GitHub Discussions),内容会保存在 GitHub 上;详见《隐私政策》。