跳到主要内容

RV1106 陪伴玩具:设备配对的设计与实现

7074 字约 18 分钟9 次阅读#rv1106#嵌入式#配对#lvgl

0. 结论

配对 = 板子请求码 → 板子显示码 → 买家输入 → 板子取 token → 拉配置,五段。

但真正让这套东西稳定的,不是这五段,而是后面的三条约束:

  1. 状态只在服务端:in_stock / activated / paired / disabled,板子和页面都不再自己猜;
  2. 每个板端请求都重新查库:token 只是“证明我是哪台设备”,能不能用要看设备当前状态;
  3. 凭证可作废: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.code

cfg-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": "已发送给设备"}

服务端校验五件事:

  1. 会话存在、未被取过、未过期;
  2. 设备属于当前买家(防止拿别人设备的码绑定);
  3. 设备当前是 active,不能是 disabled;
  4. 买家确实已经激活了这台设备(activated 状态);
  5. 通过后才签发带 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-c
sequenceDiagram
    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.state

cfg-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 上;详见《隐私政策》。