TCP OTA 客户端接入手册
通用 OTA 服务 · TCP 通道 · 客户端 / 设备端视角 · 协议版本 1
← 协议总览

TCP OTA 客户端接入手册

设备端(固件 / App / 上位机工具)接入通用 OTA 服务 TCP 通道的完整手册: 帧格式、握手与心跳、动作与字段、状态码 / 错误码全表、升级流程与易错细节。 读完本页即可独立完成客户端开发,不需要看服务端源码;文末附可直接运行的 Python 参考客户端。
服务端实现契约与运维参数见 《TCP OTA 服务端手册》。

协议版本 1(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
  • upd 1=有更新 / 0=已最新(须结合 busy 判断)
  • busy 1=服务端维护中(此时 upd 必为 0)
  • force 1=要求强制升级
  • delta 1=有可用差分补丁 / 0=无
  • nonew upd=0 时附带,便于直接分支
状态字段 · pull
  • eof 1=已到末尾
  • cnt 后随几个字节帧
  • next 下次请求的 off(直接采用)
  • done 已服务字节数(进度条)
  • again 1=队列暂满,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 响应
18011OTA 服务(本协议)设备端必须以 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 匹配响应
版本比对 / 差分选择 / 灰度 / 分片 / MD5OTA 服务只发语义请求

二、帧格式

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
偏移长度字段取值
06帧同步字固定 AA 55 5A A5 FF EC
61帧类型见 二、2、帧类型
71服务类型OTA 用 1
82会话 ID sid大端;SESS_RESP 里就是它
104数据长度大端,负载字节数
14N数据负载JSON 文本 / 原始固件字节
  • 完整帧长 = 14 + N。无填充、无帧级校验和(TCP 本身可靠;分片完整性用 chunks[].crc 可选校验)。
  • 单帧负载上限 4 MiB。帧头损坏时长度字段可能是任意值,超过 4 MiB 即判为损坏并丢弃同步重新找帧。

2、帧类型

名称值方向说明
DATA0x00双向业务帧:负载是应用层 JSON 或固件字节
SESS_REQ0x01设备 → mux请求分配会话,负载空
SESS_RESP0x02mux → 设备sid 在帧头第 8~9 字节,负载空
HEARTBEAT0x03设备 → mux心跳,负载空
HB_RESP0x04mux → 设备心跳响应,长度 0
ERROR0x05mux → 设备链路级错误,负载首字节是错误码

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 与本设备当前会话不符 → 丢弃(不是给你的)。
  • 同一连接上只跑一个会话时,也建议保留这条判断(成本极低,能挡住协议错位)。
另一侧的要求 服务端「回包必须带本请求的 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、请求字段全表

字段类型适用动作说明
actstring全部动作名
productstringcheck / start / changelog产品名 = 服务端固件目录名。按字节精确比对(区分大小写),允许中文;GBK 字节会自动归一化(见 十、1)
cvstringcheck / start / changelog当前版本,如 1.1.0(可带 v 前缀)。差分基线全靠它
hwstringcheck硬件版本。服务端原样回显,是否适用由客户端自己判断
didstringcheck / start设备唯一 ID,灰度发布必需
chunkinthello / start期望分片大小(按 九、1 区间钳制)
batchintpull期望单次返回几片(钳制到 ≤16)
offintpull从哪个字节偏移开始拉
sidintpull / finish / abort下载会话号(start 响应给的)
transstringstart"F"=全量 / "D"=差分;省略 = 服务端决策(有补丁优先差分)
tostringstart目标版本(可选)。填了就必须等于服务端当前发布版本,否则 err=2
binintpull1 = 二进制分片模式(推荐)
resultstringfinish"ok" / "fail"
md5stringfinish本地产物 MD5(服务端仅记日志)
event / msgstringreport上报事件名 / 描述(仅记日志)

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 开新会话
9off 越界(保留码;当前服务端把 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
eof1=已到末尾结束拉取循环
done已服务字节数进度条显示
crc每片 CRC32(小写 hex 8 位)可选校验
again1=发送队列暂满,一片都不发不是错误:保持原 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}
字段说明
upd1=有更新,0=已是最新(不是错误,ok 仍为 1)
nonewupd=0 时附带(值 1),便于客户端直接分支
latest服务端当前发布版本
hw硬件版本(原样回显请求里的 hw)
size完整包字节数
md5完整包 MD5(全量与差分共用这一个校验基准)
delta1=有可用差分补丁;0=无(走全量)
dsize补丁字节数(可显示「只需下载 xx KB」)
force1=服务端要求强制升级
busy1=服务端维护中(此时 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)
eof1=已到末尾
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 → 安装
                        └─ 失败 → 最终失败
差分失败不要猜、不要跳过校验 合成产物 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++;
}
这是协议要求 不是某个测试脚本的特殊处理。直连(不经过 mux)时一般不会出现插入帧,但按同一套逻辑实现更稳妥。

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
没有按帧类型过滤,把 mux 的 HB_RESP(长度 0)当成了一片数据。 见 八、1。
check 返回 upd=0 但我明明知道有新版本
先看 busy。{"ok":1,"upd":0,"busy":1} 表示服务端维护中,不是「已最新」。只有 busy=0 且 upd=0 才是真的已最新。见 五、4。
请求差分返回 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)。 传路径字符串不会报错,但会静默产出损坏结果(这是踩过的坑)。
中文产品名匹配不上
确认是 UTF-8 还是 GBK。服务端两种都认(GBK 自动归一化),但同一个产品目录名只能是一种编码。 名字上限 127 字节。
下载到一半断了
重新 start 拿新 sid,从上次的 off 继续(服务端支持任意 off)。 见 九、3、断点续传。
err=13(会话数超限)
服务端同时存在的下载会话上限 64。确认没有泄漏会话(每次都 finish 或 abort);多设备复用同一条链路时尤其要注意。
服务重启了,需要重新握手吗?
短暂掉线(<90 秒)不需要:mux 有宽限期,sid 保持不变。 掉线期间的数据帧会收到链路错误 0x03,重连后继续用原 sid 即可。
一次 pull 到底能拿多少数据?
二进制模式:batch × chunk,默认上限 16 × 8KB = 128 KB。文本模式:受单条消息 64 KB 限制,实际片数更少。 见 九、参数调优。

十三、附录: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 校验。