TCP OTA 客户端接入手册
设备端(固件 / App / 上位机工具)接入通用 OTA 服务 TCP 通道的完整手册:
帧格式、握手与心跳、动作与字段、状态码 / 错误码全表、升级流程与易错细节。
读完本页即可独立完成客户端开发,不需要看服务端源码;文末附可直接运行的 Python 参考客户端。
服务端实现契约与运维参数见 《TCP OTA 服务端手册》。
hello 响应的 pver)
传输 TCP 长度前缀帧(14 字节头)
应用消息 UTF-8 JSON
公网入口 tcp.qftek.top:32200
服务类型必须用 1
单帧负载上限 4 MiB
配套:服务端手册 →
状态码速查(完整表见 五、状态码全表)
应用层 · 响应 err(0 ~ 14)
- 0 成功
- 1 未知动作 — 停止重试
- 2 参数缺失 / 非法
- 3 产品不存在
- 4 产品已禁用 — 静默跳过
- 5 固件文件缺失
- 6 无可用补丁 — 改走全量
- 7 服务端维护中 — 静默跳过
- 8 会话失效 — 重新 start
- 9 off 越界(保留码)
- 10 灰度未命中
- 11 服务端文件 IO 失败
- 12 已是最新 — 非错误
- 13 会话数超限
- 14 客户端过旧 — 改走全量
链路级 · ERROR 帧负载首字节
- 0x01 服务未启用 — 提示配置问题,别重试
- 0x02 服务连接失败 — 稍后重试
- 0x03 服务已下线 — 稍后重试(重启中,sid 不变)
- 0x04 会话号无效 — 重新
SESS_REQ握手
状态字段 · check
upd1=有更新 / 0=已最新(须结合 busy 判断)busy1=服务端维护中(此时 upd 必为 0)force1=要求强制升级delta1=有可用差分补丁 / 0=无nonewupd=0 时附带,便于直接分支
状态字段 · pull
eof1=已到末尾cnt后随几个字节帧next下次请求的off(直接采用)done已服务字节数(进度条)again1=队列暂满,off 不变退避重发crc每片 CRC32(可选校验)
一、接入信息
1、文档信息
| 项 | 值 |
|---|---|
| 协议版本 | 1(hello 响应里的 pver) |
| 传输方式 | TCP,长度前缀帧(14 字节头) |
| 应用消息 | UTF-8 JSON |
| 公网入口 | tcp.qftek.top:32200 |
| 服务类型 | 1(必须用 1;用 0 会被路由到非 OTA 服务,收不到 OTA 响应) |
| 编码 | 请求 / 响应一律 UTF-8;产品名允许中文(GBK 设备见 十、产品名与版本号) |
2、整体链路
┌──────────┐ FRP 公网隧道 ┌──────────┐ TCP 长连接 ┌──────────────┐ │ 设备端 │ ◄──────────────► │ tcp_mux │ ◄────────────► │ OTA 服务 │ │ (IoT/App)│ tcp.qftek.top │ (转发) │ 127.0.0.1:8011 │ (业务) │ │ │ :32200 │ :8000 │ │ │ └──────────┘ └──────────┘ └──────────────┘
tcp_mux 只做完整帧透传(不拆包、不合并),所以设备端与 OTA 服务之间
等价于一条直连 TCP —— 你只需要实现帧编解码 + JSON 语义,不需要关心 mux 内部。
唯一的例外见 八、1、二进制分片必须按帧类型过滤(mux 会插入自己的控制帧)。
3、服务类型
| 值 | 端口 | 服务 | 说明 |
|---|---|---|---|
| 0 | — | 非 OTA 业务 | 与本协议无关;用 0 会被路由到非 OTA 服务,收不到 OTA 响应 |
| 1 | 8011 | OTA 服务(本协议) | 设备端必须以 svc_type = 1 发起握手与业务帧 |
4、职责划分
这是实现客户端时必须理解的边界:
| 职责 | 由谁完成 | 你(设备端)要做什么 |
|---|---|---|
| 公网接入、TLS / FRP 隧道 | 外部(FRP) | 连 tcp.qftek.top:32200 |
| 会话号 sid 分配 | tcp_mux | 发 SESS_REQ 拿到 sid |
| 心跳应答 | tcp_mux | 每 ≤90 秒发一次心跳 |
| 粘包 / 拆包(帧边界) | tcp_mux 透传 | 自己按帧长度拆包 |
按 (type, sid) 路由 | tcp_mux | 按 sid 匹配响应 |
| 版本比对 / 差分选择 / 灰度 / 分片 / MD5 | OTA 服务 | 只发语义请求 |
二、帧格式
1、帧头结构
所有多字节字段一律网络字节序(大端),帧头固定 14 字节:
┌──────────┬────────┬────────┬────────┬──────────┬─────────┐ │ 帧同步字 │ 帧类型 │ 服务类型│ 会话ID │ 数据长度 │ 数据负载 │ │ 6 bytes │ 1 byte │ 1 byte │ 2 bytes│ 4 bytes │ N bytes │ └──────────┴────────┴────────┴────────┴──────────┴─────────┘ offset 0 off 6 off 7 off 8 off 10 off 14
| 偏移 | 长度 | 字段 | 取值 |
|---|---|---|---|
| 0 | 6 | 帧同步字 | 固定 AA 55 5A A5 FF EC |
| 6 | 1 | 帧类型 | 见 二、2、帧类型 |
| 7 | 1 | 服务类型 | OTA 用 1 |
| 8 | 2 | 会话 ID sid | 大端;SESS_RESP 里就是它 |
| 10 | 4 | 数据长度 | 大端,负载字节数 |
| 14 | N | 数据负载 | JSON 文本 / 原始固件字节 |
- 完整帧长 = 14 + N。无填充、无帧级校验和(TCP 本身可靠;分片完整性用
chunks[].crc可选校验)。 - 单帧负载上限 4 MiB。帧头损坏时长度字段可能是任意值,超过 4 MiB 即判为损坏并丢弃同步重新找帧。
2、帧类型
| 名称 | 值 | 方向 | 说明 |
|---|---|---|---|
DATA | 0x00 | 双向 | 业务帧:负载是应用层 JSON 或固件字节 |
SESS_REQ | 0x01 | 设备 → mux | 请求分配会话,负载空 |
SESS_RESP | 0x02 | mux → 设备 | sid 在帧头第 8~9 字节,负载空 |
HEARTBEAT | 0x03 | 设备 → mux | 心跳,负载空 |
HB_RESP | 0x04 | mux → 设备 | 心跳响应,长度 0 |
ERROR | 0x05 | mux → 设备 | 链路级错误,负载首字节是错误码 |
3、链路级错误码
ERROR 帧负载首字节:
| 值 | 含义 | 客户端动作 |
|---|---|---|
| 0x01 | 服务未启用 | 提示配置问题,别重试 |
| 0x02 | 服务连接失败 | 稍后重试 |
| 0x03 | 服务已下线 | 稍后重试(服务重启中) |
| 0x04 | 会话号无效 | 重新 SESS_REQ 握手 |
0x03(ERR_SVC_DOWN)。
tcp_mux 有 90 秒宽限期,期间重连 sid 保持不变,无需重新握手。
三、握手、心跳与 sid 规则
1、握手(每次建连必做)
设备 ──► SESS_REQ frame_type=0x01, svc_type=1, sid=0, 负载空 设备 ◄── SESS_RESP frame_type=0x02, sid=<分配值> ← 读帧头第 8~9 字节
此后所有业务帧都带这个 sid。
2、心跳(保活)
每 ≤90 秒 发一次 HEARTBEAT(0x03,负载空),
否则 tcp_mux 会踢掉会话。服务端不处理心跳(mux 负责应答),你只需按时发。
pull 循环里也穿插发送(大固件下载可能持续数分钟),或用独立线程 / 定时器。
3、按 sid 匹配响应
设备端发出的每个业务帧都带 sid;tcp_mux 按
(type, sid) 把响应路由回对应设备。因此客户端要守住三条:
- 若你在同一条 TCP 连接上跑多个会话(多设备复用链路),必须按
sid匹配响应, 不能只按到达顺序取。 - 收到的帧
sid与本设备当前会话不符 → 丢弃(不是给你的)。 - 同一连接上只跑一个会话时,也建议保留这条判断(成本极低,能挡住协议错位)。
四、应用消息格式
1、请求
统一 JSON,字段扁平。未知字段一律忽略(便于协议向前演进),
ok 是整数 1/0,不是布尔。
check 两字),服务端按「无参动作」处理。
但只有 act 能这样省,带字段的动作必须用 JSON。
2、响应
统一起手:成功 {"ok":1, ...},失败 {"ok":0,"err":N,"msg":"..."}。
{"ok":0,"err":3,"msg":"unknown product 'Foo'"}各动作的成功响应字段见 六、动作详解;err 取值见
五、3、应用层错误码全表。
五、动作与状态码总表
1、动作总表
| 动作 | 请求体 | 必要性 |
|---|---|---|
hello | {"act":"hello"}(可带 chunk) | 可选(协商分片) |
check | {"act":"check","product":"X","cv":"1.1.0"} | 必做 |
start | {"act":"start","product":"X","cv":"1.1.0"} | 必做 |
pull | {"act":"pull","sid":12,"off":0,"batch":16,"bin":1} | 必做 |
finish | {"act":"finish","sid":12,"result":"ok"} | 必做 |
abort | {"act":"abort","sid":12} | 可选 |
report | {"act":"report","event":"...","msg":"..."} | 可选 |
changelog | {"act":"changelog","product":"X","cv":"1.1.0"} | 可选 |
2、请求字段全表
| 字段 | 类型 | 适用动作 | 说明 |
|---|---|---|---|
act | string | 全部 | 动作名 |
product | string | check / start / changelog | 产品名 = 服务端固件目录名。按字节精确比对(区分大小写),允许中文;GBK 字节会自动归一化(见 十、1) |
cv | string | check / start / changelog | 当前版本,如 1.1.0(可带 v 前缀)。差分基线全靠它 |
hw | string | check | 硬件版本。服务端原样回显,是否适用由客户端自己判断 |
did | string | check / start | 设备唯一 ID,灰度发布必需 |
chunk | int | hello / start | 期望分片大小(按 九、1 区间钳制) |
batch | int | pull | 期望单次返回几片(钳制到 ≤16) |
off | int | pull | 从哪个字节偏移开始拉 |
sid | int | pull / finish / abort | 下载会话号(start 响应给的) |
trans | string | start | "F"=全量 / "D"=差分;省略 = 服务端决策(有补丁优先差分) |
to | string | start | 目标版本(可选)。填了就必须等于服务端当前发布版本,否则 err=2 |
bin | int | pull | 1 = 二进制分片模式(推荐) |
result | string | finish | "ok" / "fail" |
md5 | string | finish | 本地产物 MD5(服务端仅记日志) |
event / msg | string | report | 上报事件名 / 描述(仅记日志) |
3、应用层错误码全表(err)
响应形如 {"ok":0,"err":3,"msg":"unknown product 'Foo'"}:
| err | 含义 | 客户端建议动作 |
|---|---|---|
| 0 | 成功 | — |
| 1 | 未知动作 | 协议不匹配,停止重试 |
| 2 | 参数缺失 / 非法 | 检查请求字段 |
| 3 | 产品不存在 | 核对 product;等固件上传后再试 |
| 4 | 产品已禁用 OTA | 该产品不该升级,静默跳过 |
| 5 | 固件文件缺失 | 服务端待修,稍后重试 |
| 6 | 请求了差分但无补丁 | 改用 trans:"F" 走全量 ★ |
| 7 | 服务端维护中(写锁) | 静默跳过,稍后重试 |
| 8 | 会话不存在 / 已过期 | 重新 start 开新会话 |
| 9 | off 越界(保留码;当前服务端把 off ≥ size 当作已完成,回 ok=1 + eof=1) | 用 pull 响应里的 next 重试 |
| 10 | 灰度未命中 | 该设备不在灰度池,静默跳过 |
| 11 | 服务端文件 IO 失败 | 稍后重试 |
| 12 | 已是最新版本 | 非错误,可映射成「无更新」 |
| 13 | 会话数超限 | 稍后重试 / 关掉多余连接 |
| 14 | 客户端版本过低不支持差分 | 改用 trans:"F" 走全量 ★ |
各错误码在服务端侧的触发条件见 服务端手册 · 错误码触发场景全表。
off 越界的特殊处理
off >= size 时返回 {"ok":1,"eof":1} 而不报错。
因为「最后一片之后再问一次」是常见实现,报错会让整个升级失败。
4、check 状态判定
| 响应特征 | 含义 | 客户端动作 |
|---|---|---|
upd=1 | 有更新 | 进入 start(delta=1 且本地有旧固件时可优先差分) |
upd=0, busy=1 | 服务端维护中 | 静默跳过,别当成「已最新」(该响应故意不带 latest) |
upd=0, busy=0, nonew=1 | 确实已是最新 | 结束流程 |
force=1 | 服务端要求强制升级 | UI 提示为强制更新(结合自身策略处理) |
ok=0, err=N | 请求失败 | 按 错误码全表处理 |
{"ok":1,"upd":0,"busy":1}
—— 所以判断时不能只看 upd。
5、pull 状态与流控
| 字段 | 取值 / 含义 | 客户端动作 |
|---|---|---|
cnt | 后面紧跟几个字节帧 | 按 八、1 过滤帧类型后逐片接收 |
next | 下一次应当请求的偏移 | 直接拿它当下一轮的 off |
eof | 1=已到末尾 | 结束拉取循环 |
done | 已服务字节数 | 进度条显示 |
crc | 每片 CRC32(小写 hex 8 位) | 可选校验 |
again | 1=发送队列暂满,一片都不发 | 不是错误:保持原 off 退避重发(建议 50 ms) |
六、动作详解(报文与字段)
1、hello —— 分片协商
{"ok":1,"pver":1,"chunk":8192,"min":128,"max":65535}| 字段 | 说明 |
|---|---|
pver | 协议版本(当前 1) |
chunk | 服务端建议分片大小(已按你的请求钳制) |
min / max | 本协议允许区间(128 / 65535) |
hello 里带上 product,服务端会把该产品配置里的
推荐分片大小回给你(仍按区间钳制)。不带也能用,start 时服务端会自行取值。
2、check —— 检查更新
{"ok":1,"latest":"1.1.5","upd":1,"busy":0,"force":0,"hw":"1.0.0",
"size":36875511,"md5":"82c6aa016c033b037492bd3cae32df79",
"delta":1,"dsize":2311859}| 字段 | 说明 |
|---|---|
upd | 1=有更新,0=已是最新(不是错误,ok 仍为 1) |
nonew | upd=0 时附带(值 1),便于客户端直接分支 |
latest | 服务端当前发布版本 |
hw | 硬件版本(原样回显请求里的 hw) |
size | 完整包字节数 |
md5 | 完整包 MD5(全量与差分共用这一个校验基准) |
delta | 1=有可用差分补丁;0=无(走全量) |
dsize | 补丁字节数(可显示「只需下载 xx KB」) |
force | 1=服务端要求强制升级 |
busy | 1=服务端维护中(此时 upd 必为 0,别当成「已最新」) |
3、start —— 开始下载
{"ok":1,"sid":12,"trans":"D","size":2311859,"total":142,
"chunk":8192,"md5":"82c6aa016c033b037492bd3cae32df79"}| 字段 | 说明 |
|---|---|
sid | 下载会话号,后续 pull / finish 必须带回 |
trans | 实际采用的方式(可能与你请求的不同,以这个为准) |
size | 本次传输对象的字节数:F → 完整包大小;D → 补丁大小(不是完整包!) |
total | 总分片数 |
chunk | 实际生效的分片大小 |
md5 | 目标完整包 MD5。差分路径下 == 「旧包 + 补丁」合成产物应有的 MD5,必须校验 |
4、pull —— 二进制模式(bin=1,推荐)
先到一个 JSON 帧:
{"ok":1,"cnt":16,"next":131072,"eof":0,"done":131072,"size":36875511,"bin":1,
"chunks":[{"off":0,"len":8192,"crc":"a1b2c3d4"},
{"off":8192,"len":8192,"crc":"e5f6a7b8"}]}紧随其后是 cnt 个 DATA 帧,每帧装一片原始字节(同 sid)。
| 字段 | 说明 |
|---|---|
cnt | 后面跟几个字节帧 |
next | 下一次应当请求的偏移(直接拿它当 off) |
eof | 1=已到末尾 |
done | 已服务字节数(进度条用) |
crc | 每片 CRC32(小写 hex 8 位),可选校验 |
5、pull —— 文本模式(bin=0)
单片数据以 hex 内嵌 JSON,一个帧搞定,但体积 ×2:
{"ok":1,"cnt":1,"next":8192,"eof":0,"done":8192,
"chunks":[{"off":0,"len":8192,"d":"48656c6c6f..."}]}6、pull —— 流控信号(again)
服务端发送队列暂满时,一片都不发,返回:
{"ok":1,"cnt":0,"again":1,"next":<原偏移>,"eof":0}off —— 保持原 off 退避重发(建议 50 ms)。
服务端保证全有或全无:要么完整入队 cnt 片,要么返回 again=1,
绝不出现「JSON 声明 N 片但实际只发 M 片」(后者会让客户端读到 0 长度分片而整包失败)。
详见 八、2。
7、finish —— 下载完成
{"ok":1,"sid":12,"size":36875511,"md5":"82c6aa016c033b037492bd3cae32df79"}请求体的 result 用 "ok" / "fail";
md5 是本地产物的 MD5(服务端仅记日志,用于对账排障)。
8、abort —— 中止会话(可选)
请求:{"act":"abort","sid":12}
响应:{"ok":1} 或 {"ok":0,"err":N,"msg":"..."}中途放弃下载时调用,释放服务端下载会话。响应遵循统一约定,
失败时按 错误码全表处理(如 err=8 会话不存在 / 已过期)。
9、report —— 事件上报(可选)
请求:{"act":"report","event":"patch_md5_mismatch","msg":"..."}
响应:{"ok":1} 或 {"ok":0,"err":N,"msg":"..."}服务端仅记日志,不参与升级决策。典型用途:把客户端侧的失败原因(合成 MD5 不符、写盘失败等) 上报到服务端日志,便于现场排障。
10、changelog —— 更新日志
{"ok":1,"content":"v1.1.5\n\n### 新增\n- ...\n"}| 字段 | 说明 |
|---|---|
content | 更新日志全文(UTF-8 纯文本) |
- ⚠️ 没有日志文件时返回
ok=1+content:"",不是错误。拿不到当空串即可,绝不阻断升级。 cv目前可忽略:服务端一律返回「最新版本的日志」。- 日志超过 60 KB 会返回
err=11(而不是截断成半截 JSON)。 - 错误分支与
check对齐,逐项如下:- 缺
product→err=2 - 未知产品 →
err=3 - 产品停用 →
err=4 - 灰度未命中 →
err=10 - 服务端维护中 →
err=7 - 读日志文件失败 →
err=11
- 缺
七、完整升级流程
1、全量升级
握手:SESS_REQ ──► ◄── SESS_RESP (sid=N)
hello {"act":"hello","chunk":8192} (可选,协商分片)
check {"act":"check","product":P,"cv":cur,"hw":hw} → upd / size / md5 / delta
└─ upd=0 → 无更新,结束
start {"act":"start","product":P,"cv":cur,"trans":"F","chunk":r.chunk}
→ sid / size / total / md5
循环 pull(bin=1)直到 eof=1:
{"act":"pull","sid":sid,"off":off,"batch":16,"bin":1}
→ cnt / next / eof + cnt 个字节帧(按八、1 过滤)
校验 md5(产物) == 期望 md5 ← ★ 必须
finish {"act":"finish","sid":sid,"result":"ok","md5":md5(产物)}
安装2、差分升级
前段与全量相同,check 响应 delta=1 时选择差分:
start {"act":"start","product":P,"cv":cur,"trans":"D"}
→ sid / trans="D" / size=<补丁大小> / md5=<目标完整包 MD5>
循环 pull → 得到 patch 字节
合成 new_img = bspatch(本地旧固件, patch) ← BSDIFF40 格式
校验 md5(new_img) == start.md5 ← ★ 必须,唯一护栏
finish {"act":"finish","sid":sid,"result":"ok","md5":md5(new_img)}
安装补丁格式:标准 BSDIFF40(bzip2 三段:ctrl + diff + extra),与开源
bsdiff4(Python)、BsPatch.kt(Android)、
bspatch(C)完全兼容。
3、失败降级链
① 补丁下载失败 ────┐
② bspatch 合成失败 ─┼──► 回退全量下载(清理补丁与损坏产物)
③ 合成产物 MD5 不符 ┘ │
├─ 成功 → 校验 MD5 → 安装
└─ 失败 → 最终失败4、changelog(可选,与升级解耦)
可独立调用,用于在 UI 上显示更新内容:
{"act":"changelog","product":P,"cv":cur} → {"ok":1,"content":"..."}拿不到(ok=1 但 content:"")就当空串显示,不要阻断升级流程。
八、三个必须实现的细节
1、二进制分片必须按帧类型过滤
在经 tcp_mux 接入时,链路是复用的,mux 会在那 cnt 个字节帧
中间插入它自己的控制帧(典型是长度 0 的 HB_RESP)。
若客户端只数「读了 N 帧」,把长度 0 的 HB_RESP 当成一片数据,就会出现:
分片长度不符: 期望 8192 得到 0 ← 整包失败
必须按帧类型过滤,只把 frame_type == 0x00(DATA)的帧计入:
int got = 0;
while (got < json.cnt) {
f = recv_frame();
if (f->frame_type != 0x00) /* ★ 关键:HB_RESP / ERROR 等一律跳过 */
continue;
memcpy(buf + chunks[got].off, f->data, f->len);
if (chunks[got].len != f->len) /* 长度必须匹配,否则帧流已错位 */
return ERROR;
got++;
}2、流控信号 again
while (1) {
r = pull(sid, off, batch, bin=1);
if (r.again) { sleep_ms(50); continue; } /* 退避重发,off 不变 */
off = r.next;
break;
}服务端保证全有或全无:要么完整入队 cnt 片,要么返回 again=1,
绝不出现「JSON 声明 N 片但实际只发 M 片」(后者会让客户端读到 0 长度分片而整包失败)。
3、chunks[i].off 是目标文件里的偏移
不是「第 i 片」。按 off 落盘更稳(当前实现顺序发送,但不要依赖顺序假设)。
九、分片与批量调参
1、chunk(hello / start)
服务端钳制到 [min, max] = [128, 65535]:
- 太小 → 往返多
- 太大 → 设备缓冲吃紧 / 单帧过大
8 KB 是常用值:1 MB 固件只需 128 次往返,多数 MCU 也扛得住。
chunk 时,服务端按 TCP 通道默认值(8 KB)下发;传了会被钳制到
[128, 65535]。产品的推荐值由服务端配置,客户端无需关心 ——
需要核对时见 服务端手册 · 分片与传输参数。
2、batch(pull)
钳制到 [1, 16](TCP 通道 MAX_BATCH=16):
16 × 8KB = 128 KB / 次- 文本模式下还会按消息上限(64 KB)反推安全片数
batch 8~16 + chunk 8KB。
文本模式(bin=0)只适合小补丁或调试:体积 ×2,且要在 MCU 上解 hex。
3、断点续传
服务端不强制顺序拉取 —— off 完全由客户端指定(固件已 mmap,任意定位零成本):
- 顺序模式:每次用响应里的
next当下次的off - 断点续传:设备重启后重新
start(拿新sid),从上次的off继续 - 随机读取:任意
off都支持
十、产品名与版本号
1、product
- = 服务端固件库的目录名,按字节精确比对(区分大小写)。
- 允许中文(UTF-8)。名字上限 127 字节。
- GBK 自动兼容:若你的固件把中文名写死成 GBK 字节(老设备常见), 服务端会自动 GBK → UTF-8 归一化后再比一次,无需你改动。 例如发 GBK 字节的「喵账」与发 UTF-8 的「喵账」都能匹配到同一产品。
- 建议:新设备统一用 UTF-8。
2、cv
- 形如
X.Y.Z(各段 1~4 位数字,可带v前缀,v1.1.0等价1.1.0)。 - 决定两件事:① 是否「已是最新」 ② 用哪个补丁基线。
- 服务端按「不相等」判定更新:
cv与服务端当前发布版本一致才算「已是最新」; 不一致(更高或更低)都会返回有更新 —— 这是为了支持服务端降级发布。 - 所以把
cv填得比服务端还高,服务端仍会下发更新(实为降级), 是否安装由客户端自行判断 —— 别随便填版本号。 - 对应不上任何补丁基线 → 差分不可用(此时自动回退全量即可)。
十一、会话与超时
| 项 | 值 | 说明 |
|---|---|---|
下载会话 sid 空闲超时 | 600 秒 | pull / finish 都算活动,持续下载不会被回收 |
| 心跳间隔 | ≤90 秒 | 超时 mux 会踢会话 |
| mux 掉线宽限期 | 90 秒 | 期间重连 sid 不变,无需重新握手 |
| 并发下载会话上限 | 64 | 服务端全局上限;用满后新 start 返回 err=13,用完务必 finish / abort |
| 设备断线 | — | mux 断开对应会话,服务端会话随后按空闲超时回收 |
| 服务端重启 | — | 下载会话丢失,需重新 start(check 结果仍有效) |
十二、常见问题
分片长度不符: 期望 8192 得到 0
HB_RESP(长度 0)当成了一片数据。
见 八、1。check 返回 upd=0 但我明明知道有新版本
请求差分返回 err=6 或 err=14
trans:"F" 走全量。start 返回的 size 比我以为的固件小很多
size 是补丁大小,不是完整包大小。
校验基准看 md5(那是完整包的 MD5)。合成产物 MD5 总是对不上
bspatch 的入参类型。Python
bsdiff4.patch(src, patch) 要字节:
bsdiff4.patch(open(old, 'rb').read(), patch_bytes)。
传路径字符串不会报错,但会静默产出损坏结果(这是踩过的坑)。中文产品名匹配不上
下载到一半断了
err=13(会话数超限)
finish 或
abort);多设备复用同一条链路时尤其要注意。服务重启了,需要重新握手吗?
0x03,重连后继续用原 sid 即可。一次 pull 到底能拿多少数据?
十三、附录:Python 参考客户端
依赖:仅标准库;仅差分合成需要 bsdiff4(pip install bsdiff4)。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""TCP OTA 参考客户端 —— 完整实现了本文件的全部协议要求。"""
import struct, socket, json, time, hashlib, zlib, sys
SYNC = b'\xAA\x55\x5A\xA5\xFF\xEC'
HDR_LEN = 14
MAX_DATA = 4 * 1024 * 1024
FT_DATA, FT_SESS_REQ, FT_SESS_RESP, FT_HB, FT_HB_RESP, FT_ERR = 0x00, 0x01, 0x02, 0x03, 0x04, 0x05
SVC_OTA = 1
# ── 帧编解码 ────────────────────────────────────────────────────────────────
def build_frame(ft, svc, sid, payload=b''):
return SYNC + struct.pack('>BBHI', ft, svc, sid, len(payload)) + payload
class FrameReader:
"""按帧长度拆包;帧头损坏时重新找同步字。"""
def __init__(self, sock):
self.s = sock
self.buf = bytearray()
def _fill(self, n):
while len(self.buf) < n:
b = self.s.recv(65536)
if not b:
raise ConnectionError('连接已关闭')
self.buf += b
def read(self):
"""返回 (frame_type, svc_type, sid, payload)。"""
while True:
# 找同步字
i = self.buf.find(SYNC)
if i < 0: # 还没有完整同步字,丢多余的
keep = len(SYNC) - 1
del self.buf[:max(0, len(self.buf) - keep)]
self._fill(len(SYNC))
continue
if i > 0:
del self.buf[:i] # 丢掉同步字之前的垃圾
self._fill(HDR_LEN)
ft, svc, sid, dlen = struct.unpack('>BBHI', self.buf[6:HDR_LEN])
if dlen > MAX_DATA: # 帧头损坏,跳一个字节重找
del self.buf[:1]
continue
self._fill(HDR_LEN + dlen)
payload = bytes(self.buf[HDR_LEN:HDR_LEN + dlen])
del self.buf[:HDR_LEN + dlen]
return ft, svc, sid, payload
class OtaClient:
def __init__(self, host, port):
self.sock = socket.create_connection((host, port), timeout=30)
self.reader = FrameReader(self.sock)
self.sid = 0
self.last_hb = 0.0
# ── 握手 ──
def handshake(self):
self.sock.sendall(build_frame(FT_SESS_REQ, SVC_OTA, 0))
while True:
ft, svc, sid, _ = self.reader.read()
if ft == FT_SESS_RESP:
self.sid = sid
self.last_hb = time.time()
return sid
# 忽略握手前可能到达的其它帧
def heartbeat(self, force=False):
if force or time.time() - self.last_hb > 30:
self.sock.sendall(build_frame(FT_HB, SVC_OTA, self.sid))
self.last_hb = time.time()
# ── 应用层请求:发一帧、收到一个 JSON 帧 ──
def request(self, obj):
self.heartbeat()
payload = json.dumps(obj, ensure_ascii=False).encode('utf-8')
self.sock.sendall(build_frame(FT_DATA, SVC_OTA, self.sid, payload))
while True:
ft, svc, sid, data = self.reader.read()
if ft == FT_ERR:
raise RuntimeError(f'链路错误码 0x{data[0]:02x}')
if ft != FT_DATA:
continue # ★ 跳过 HB_RESP 等插入帧
return json.loads(data.decode('utf-8'))
# ── 高层流程 ────────────────────────────────────────────────────────────────
def do_check(c, product, cv, hw='', did=''):
return c.request({'act': 'check', 'product': product, 'cv': cv,
'hw': hw, 'did': did})
def do_changelog(c, product, cv):
r = c.request({'act': 'changelog', 'product': product, 'cv': cv})
return r.get('content', '') if r.get('ok') else ''
def do_start(c, product, cv, trans=None, chunk=None, did=''):
req = {'act': 'start', 'product': product, 'cv': cv, 'did': did}
if trans:
req['trans'] = trans
if chunk:
req['chunk'] = chunk
return c.request(req)
def pull_all(c, sess_sid, total, batch=16, on_progress=None):
"""二进制模式拉取全部字节;处理 again 流控与插入帧。"""
buf = bytearray(total)
off = 0
while off < total:
while True: # 处理 again
r = c.request({'act': 'pull', 'sid': sess_sid, 'off': off,
'batch': batch, 'bin': 1})
if not r.get('ok'):
raise RuntimeError(f"pull 失败 err={r.get('err')} {r.get('msg','')}")
if r.get('again'):
time.sleep(0.05) # ★ 不退 off,退避重发
continue
break
cnt = r['cnt']
for i in range(cnt):
# ★ 按帧类型过滤,只数 DATA 帧
while True:
ft, svc, sid, data = c.reader.read()
if ft == FT_ERR:
raise RuntimeError(f'链路错误码 0x{data[0]:02x}')
if ft == FT_DATA:
break
meta = r['chunks'][i]
if len(data) != meta['len']:
raise RuntimeError(f"分片长度不符: 期望 {meta['len']} 得到 {len(data)}")
# 可选:CRC32 校验
crc_want = int(meta['crc'], 16)
if zlib.crc32(data) & 0xFFFFFFFF != crc_want:
raise RuntimeError(f"分片 CRC 不符 off={meta['off']}")
buf[meta['off']:meta['off'] + meta['len']] = data
off = r['next']
if on_progress:
on_progress(off, total)
if r.get('eof'):
break
return bytes(buf)
def do_finish(c, sess_sid, result, md5):
return c.request({'act': 'finish', 'sid': sess_sid,
'result': result, 'md5': md5})
def upgrade(host, port, product, cv, old_firmware=b'',
hw='', did='', allow_delta=True):
"""完整升级流程(含差分优先 + 失败回退全量)。
返回 (产物字节, 说明字符串)。异常表示升级失败。
"""
c = OtaClient(host, port)
c.handshake()
c.request({'act': 'hello', 'chunk': 8192})
info = do_check(c, product, cv, hw, did)
if not info.get('ok'):
raise RuntimeError(f"check 失败 err={info.get('err')} {info.get('msg','')}")
if not info.get('upd'):
return None, f"已是最新(busy={info.get('busy',0)})"
want_md5 = info['md5']
chunk = info.get('chunk', 8192)
# ── 差分优先 ──
if allow_delta and info.get('delta') and old_firmware:
try:
print(f"[差分] 补丁 {info['dsize']} 字节 "
f"(完整包的 {info['dsize']*100//info['size']}%)")
s = do_start(c, product, cv, trans='D', chunk=chunk, did=did)
if not s.get('ok'):
raise RuntimeError(f"start(D) err={s.get('err')}")
patch = pull_all(c, s['sid'], s['size'],
on_progress=lambda d, t: print(f"\r 下载 {d*100//t}%", end=''))
print()
import bsdiff4
newimg = bsdiff4.patch(old_firmware, patch) # ← 传**字节**,不是路径!
got = hashlib.md5(newimg).hexdigest()
if got != want_md5:
raise RuntimeError(f"合成产物 MD5 不符: {got} != {want_md5}")
do_finish(c, s['sid'], 'ok', got)
return newimg, f"差分成功(补丁 {len(patch)} 字节,节省 "
except Exception as e:
print(f"[差分] 失败:{e} → 回退全量") # ★ 必须回退
# ── 全量 ──
s = do_start(c, product, cv, trans='F', chunk=chunk, did=did)
if not s.get('ok'):
raise RuntimeError(f"start(F) err={s.get('err')}")
img = pull_all(c, s['sid'], s['size'],
on_progress=lambda d, t: print(f"\r 下载 {d*100//t}%", end=''))
print()
got = hashlib.md5(img).hexdigest()
if got != want_md5:
do_finish(c, s['sid'], 'fail', got)
raise RuntimeError(f"全量 MD5 不符: {got} != {want_md5}")
do_finish(c, s['sid'], 'ok', got)
return img, f"全量成功({len(img)} 字节)"
if __name__ == '__main__':
host = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1'
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8011
prod = sys.argv[3] if len(sys.argv) > 3 else '喵账'
ver = sys.argv[4] if len(sys.argv) > 4 else '1.1.0'
c = OtaClient(host, port)
print('握手 sid =', c.handshake())
print('hello ', c.request({'act': 'hello', 'chunk': 8192}))
print('check ', do_check(c, prod, ver))
print('changelog 前 80 字:', do_changelog(c, prod, ver)[:80])
img, msg = upgrade(host, port, prod, ver)
print('结果:', msg, f'MD5={hashlib.md5(img).hexdigest()}' if img else '')运行方式
python3 ota_client.py 127.0.0.1 8011 喵账 1.1.0 # 直连服务端口 python3 ota_client.py tcp.qftek.top 32200 喵账 1.1.0 # 走公网 + mux
该脚本已覆盖本文件全部要点:握手、心跳、again 流控、插入帧过滤、
分片 CRC、全量 / 差分双路径、失败回退、MD5 校验。