feat(ppclock): SUOTA 固件升级(ota.py + CLI ota 子命令 + 预检),APK互证报告入库,123 测试全绿
This commit is contained in:
1 parent
7079053dde
commit
3b92c382fc
7 files changed
+443
No files matched your search
@@ -5,3 +5,7 @@
|
|||||||
| 2026-07-24 | 以 Web 上位机 JS 为协议第一证据源 | 三条证据线:Web JS / APK / 固件 | Web Bluetooth JS 通常未重度混淆,可读性最高,可 fastest path 拿到 UUID 与命令格式,再与 APK/固件互证 |
|
| 2026-07-24 | 以 Web 上位机 JS 为协议第一证据源 | 三条证据线:Web JS / APK / 固件 | Web Bluetooth JS 通常未重度混淆,可读性最高,可 fastest path 拿到 UUID 与命令格式,再与 APK/固件互证 |
|
||||||
| 2026-07-24 | CLI 用 Python+bleak | 本地化+agent 化需求 | bleak 跨平台、无需 root 以外依赖;JSON I/O 便于 agent 编排;Pillow 覆盖全部抖动算法 |
|
| 2026-07-24 | CLI 用 Python+bleak | 本地化+agent 化需求 | bleak 跨平台、无需 root 以外依赖;JSON I/O 便于 agent 编排;Pillow 覆盖全部抖动算法 |
|
||||||
| 2026-07-24 | 项目纳入 git 管理 | 治理规则要求 | 原始目录非 git 仓库;逆向产物多、需频繁提交与回溯 |
|
| 2026-07-24 | 项目纳入 git 管理 | 治理规则要求 | 原始目录非 git 仓库;逆向产物多、需频繁提交与回溯 |
|
||||||
|
| 2026-07-24 | 倒计时前缀位图帧**不**复刻 JS 的 off-by-one | timedjs2.js:206-224 循环 `i < len-1` 丢末字节 | 判定为 JS 端笔误(设备缓冲兼容完整帧);CLI 发完整 n*32 字节,避免在末字符右下角丢 8 像素 |
|
||||||
|
| 2026-07-24 | 倒计时日期/休眠小时采用 BCD 编码 | timedjs2.js:242-243 / ble.js:4013-4015 均为十进制字符串直拼后经 hexToBytes | 实测 JS 证据:month=12 → 字节 0x12,hour=23 → 0x23;与 setTime 的真 hex(intToHex)不同,需注意区分 |
|
||||||
|
| 2026-07-24 | 固件头格式确认(magic 'pQ'/flags 0x01AA/长度/CRC32(body)/64B 头/0x20 填充 0x00) | 以 zlib.crc32 反推验证 CRC 字段 = body 的 CRC32,重打包字节级往返一致 | fw_pack 可生成与原镜像格式一致的文件,是固件可控(G4)的基础 |
|
||||||
|
| 2026-07-24 | 中英混排 0.5 单位的倒计时前缀在 CLI 拒绝 | JS `'0'+charCount` 遇 3.5 生成非法 hex "03.5" | 属于上游 bug 路径,CLI 显式报错优于发送畸形帧 |
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# qianbian Android APK 逆向分析报告(client-qbsg_v1.1.0.apk)
|
||||||
|
|
||||||
|
> 分析方法:androguard 4.1.4 静态分析(DEX 字符串表 + xref + DAD 反编译目标类);native 库 strings/导出符号粗扫。
|
||||||
|
> 对照基线:analysis/web/protocol-raw.md(下称"Web 报告")。
|
||||||
|
> 分析产物:analysis/apk/{classes.txt, classes-qbsg.txt, strings-clean.txt, xrefs-key-strings.txt, decomp/*.java, native/libapp.so, native/libeditor.so}
|
||||||
|
|
||||||
|
## 0. APK 基本信息
|
||||||
|
|
||||||
|
| 项 | 值 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 包名 | `io.github.bszapp.dzjq42` | AndroidManifest |
|
||||||
|
| 版本 | v1.1.0-20260612-a2c9488 (versionCode 4) | AndroidManifest |
|
||||||
|
| minSdk / targetSdk | 24 / 37 | AndroidManifest |
|
||||||
|
| 主 Activity | `qbsg.top.MainActivity` | AndroidManifest |
|
||||||
|
| 权限 | BLUETOOTH/ADMIN/SCAN/CONNECT、ACCESS_FINE_LOCATION、INTERNET、**REQUEST_INSTALL_PACKAGES** | AndroidManifest |
|
||||||
|
| native 库 | arm64-v8a: libapp.so (1.5MB)、libeditor.so (3.8MB)、libandroidx.graphics.path.so | APK lib/ |
|
||||||
|
| 技术栈 | Kotlin Compose Multiplatform;共享逻辑经 R8 混淆(`La0/*`、`Lz7/*` 等);`qbsg.top.*` 为保留名的 Android 平台层;libapp.so/libeditor.so 为 **Rust** 编译(含 sha2 0.10.9、aes 0.8.4、rust-secp256k1 0.11 crate 路径字符串) | classes.txt、libapp.so strings |
|
||||||
|
|
||||||
|
应用结构要点:
|
||||||
|
- `qbsg.top.ble.BleManager/BleSession`:Android GATT 平台层(未混淆)。
|
||||||
|
- `e8.o`(抽象 EpdManager)两个实现:`c8.g`(固件家族名"平平/扫地",`j()` 返回)与 `d8.f`("轻舞/时光")——**APK 显式支持至少两个固件家族**,协议细节有差异(见 §3/§4)。
|
||||||
|
- `z7.b0`:独立 SUOTA 管理器(扫描→连接→SPOTA 全流程,20.6 万字符反编译代码)。
|
||||||
|
- `a8.g`:经已有会话的 OTA 通道(GattOtaChannel)。
|
||||||
|
- `qbsg.top.ble.Simulated*`:整套模拟设备框架("NRF-模拟设备"演示模式,SimulatedBleKt.buildSimulatedProfiles 引用 "NRF-")。
|
||||||
|
- `qbsg.top.template.editor.LibEditor`:JNI 桥到 libeditor.so(Rust 模板编辑器,导出 create/renderPng/exportBitmap/createElement 等)。
|
||||||
|
|
||||||
|
## a. GATT UUID 对照
|
||||||
|
|
||||||
|
证据:`qbsg.top.ble.BleManagerKt.<clinit>`(decomp/qbsg_top_ble_BleManagerKt.java:11-21)。
|
||||||
|
|
||||||
|
| UUID | 用途 | 与 Web 一致? |
|
||||||
|
|---|---|---|
|
||||||
|
| `13187b10-eba9-a3ba-044e-83d3217d9a38` | EPD_SERVICE_UUID | ✅ 一致 |
|
||||||
|
| `4b646063-6264-f3a7-8941-e65356ea82fe` | EPD_CHAR_UUID | ✅ 一致 |
|
||||||
|
| `00001f10-0000-1000-8000-00805f9b34fb` | TX_SERVICE_UUID(RXTX) | ✅ 一致 |
|
||||||
|
| `00001f1f-0000-1000-8000-00805f9b34fb` | TX_CHAR_UUID | ✅ 一致 |
|
||||||
|
| `00002902-...` | CCCD | Web 未显式用(Web BLE 隐式) |
|
||||||
|
| `00002A24-...` / `00002A26-...` | MODEL_NUMBER / FIRMWARE_REVISION(DIS) | **Web 端没有**(APK 连接后读型号+固件版本,BleManager$startDeviceInfoRead$1.java:47-56) |
|
||||||
|
|
||||||
|
**Web 端没有、APK 新增的 UUID**(全部用于 OTA,详见 §e):
|
||||||
|
|
||||||
|
| UUID | 角色 |
|
||||||
|
|---|---|
|
||||||
|
| `0000fef5-0000-1000-8000-00805f9b34fb` | Dialog SPOTA 服务(z7_b0.java:666 等) |
|
||||||
|
| `64b4e8b5-0de5-401b-a21d-acc8db3b913a` | SPOTA_SERV_STATUS(z7_b0.java:768) |
|
||||||
|
| `8082caa8-41a6-4021-91c6-56f9b954cc34` | SPOTA_MEM_DEV(z7_b0.java:679) |
|
||||||
|
| `724249f0-5ec3-4b5f-8804-42345af08651` | SPOTA_GPIO_MAP(z7_b0.java:677) |
|
||||||
|
| `457871e8-d516-4ca1-9116-57d0b17b9cb2` | **本固件的 SPOTA_PATCH_DATA**(z7_b0.java:665/1029 错误文案"SPOTA_PATCH_DATA未找到"对应此 UUID;注意这是 Dialog 标准里的 PATCH_LEN UUID,厂商做了自定义映射) |
|
||||||
|
| `9d84b9a3-000c-49d8-9183-855b673fda31` | **本固件的 SPOTA_PATCH_LEN**(z7_b0.java:670/1023;非标准 UUID) |
|
||||||
|
| `42c3dfdd-77be-4d9c-8454-8f875267fb3b` | SPOTA_MEM_INFO(兼容探测,z7_b0.java:772、a8_g.java:255) |
|
||||||
|
| `61c8849c-f639-4765-946e-5c3419bebb2a` / `b7de1eea-823d-43bb-a3af-c4903dfce23c` | OTA 兼容探测用备选特征(z7_b0.java:769-770、a8_g.java 头部) |
|
||||||
|
| `0000221f-...`(Web 声明未用) | APK strings 中**未出现** |
|
||||||
|
| `258EAFA5-E914-47DA-95CA-C5AB0DC85B11` | 仅字符串表存在,无代码 xref |
|
||||||
|
|
||||||
|
设备名过滤:代码扫描不过滤(`startScan(0, ...)` 无 ScanFilter,BleManager.java:761),UI 层按 `name.startsWith("NRF-")` 过滤(a9_b0.java:102;SUOTA 扫描同,a9_v2.java:190)。与 Web `namePrefix:'NRF-'` 一致。
|
||||||
|
|
||||||
|
连接参数:连接后 `requestMtu(512)`(BleManager$startDeviceInfoRead$1.java:80),`onMtuChanged` 更新会话 negotiatedMtu(默认 23);`BleSession.getMaxPayloadBytes() = max(20, mtu-3)`(BleSession.java:162-170)。connectGatt 用 TRANSPORT_LE(2)、autoConnect=false(BleManager.java:619)。
|
||||||
|
|
||||||
|
## b. 命令集对照
|
||||||
|
|
||||||
|
APK 命令发送模型与 Web 不同:**命令列表执行器** `e8.j`——把 `["e10201","TIME","e2"]` 这类列表逐条经 RXTX `sendCommand` 发出,`"TIME"` 占位符运行时替换为 `e8.o.m()` 构造的 `dd…` 时间命令(e8_j.java:111-114)。`sendCommand` 按 MTU-3 分块、互斥锁串行、10s 超时(BleSession.java:187-258)。
|
||||||
|
|
||||||
|
| 命令 | APK 证据 | 与 Web 一致? |
|
||||||
|
|---|---|---|
|
||||||
|
| `dd{unix:4B}{year:2B}{mo}{d}{week}` 设置时间 | `e8.o.m()`(e8_o.java:57-73):unix 秒+时区偏移、4B/2B/1B/1B/1B 大端 hex | ✅ 完全一致(Web ble.js:251-257) |
|
||||||
|
| `e2` 提交 | 所有命令列表收尾(c8_g.java:63 等) | ✅ |
|
||||||
|
| 模式切换 e100/e101/e102 系列 | 动态构造:行 0/1/2 → 前缀 `e102`/`e101`/`e100`(c8_g.java:39-54、s() in d8_f.java:135-150);`e1020{n+1}` 时钟、`e1010{n+3}` 日历、`e10000` 图片(c8_g.java:137-146) | ✅ 覆盖 Web 的 e10000-03/e10103-05/e10201-03 |
|
||||||
|
| 停车牌 `ef`+号码 | `c8.g.d()`:**逐字符双写**("12"→`ef1122`,c8_g.java:72-91);`d8.f.d()`:**逐字符转 hex 拼接**(Integer.toString(char,16),d8_f.java:168-189)——两固件家族格式不同!均校验 11 位数字 | ⚠️ Web 只有双写式;d8/f 的 hex 拼接式 Web 没有 |
|
||||||
|
| 倒计时文字位图 `04`/`0{n}`/`990{j}` | `f8.a.c()`(f8_a.java:70-162):gridSize 16 → ≤4 字 `0{n}` 单包、5-8 字 `04`+前段 / `0{n}`+128B 后段;gridSize 24 → 每字 `990{j}`+72B;位图 MSB-first | ✅ 与 Web timedjs2 一致 |
|
||||||
|
| 倒计时日期 `ff00`/`ffff`+`%04d%02d%02d` | `f8.a.b()`(f8_a.java:58-68):gridSize≠24→`ff00`,24→`ffff`,年为 4 位十进制 ASCII | ✅ 一致 |
|
||||||
|
| `FA` 关倒计时 | 字符串表存在(`fa`),命令列表中未直接出现(UI 文案有"关闭") | ≈ |
|
||||||
|
| 全刷/其他设置(e3/e4/e5/e6/14/15/2300/2301/FB/e901/EA0x) | **DEX 字符串与 xref 中均无**(xref 验证:'2300'/'2301'/'fb01'/'e901'/'EA00'/'EFEF' → 0 引用) | ❌ **APK 不实现**(三色/黑白切换、12/24h、颜色、反色、字体、旋转、LUT 校准 E6、休眠时段 FB、轮播 e901/EA000、模板槽位 EA0x 均为 Web 独有) |
|
||||||
|
| `EFEF` 请求设备 ID | **无**(xref=0)。APK 不读设备 14B ID,改用 DIS 2A24/2A26 + MAC 识别设备 | ❌ Web 独有 |
|
||||||
|
| WiFi 配置 `WIFI_SSID:...` | **无**(strings 无 WIFI_SSID,UI 无 WiFi 入口) | ❌ Web 独有 |
|
||||||
|
| 激活 `EF{code}` 发设备 | **无**(APK 激活是 App 本地的,见 §d) | ❌ 机制完全不同 |
|
||||||
|
| `$IMG(0,0)`(hex `24494d4728302c3029`) | c8/f 图片上传末尾经 RXTX 发送(c8_f.java:186) | ❌ **APK 独有**,Web 无此命令 |
|
||||||
|
| 上传开始 `8000`/`8001`、`0000`、`020000`,结束 `0101` | 见 §c | ❌ 帧格式与 Web 不同(见差异节) |
|
||||||
|
|
||||||
|
## c. 图像传输与像素处理
|
||||||
|
|
||||||
|
### 发送流程(EPD 通道,`BleSession.sendEpdBytes`,带写完成回调、互斥锁、5s 超时)
|
||||||
|
|
||||||
|
`c8.f`("平平/扫地"固件,c8_f.java:137-200):
|
||||||
|
1. EPD `{80,00}`(`8000`)
|
||||||
|
2. EPD `{00,00}`(`0000`)
|
||||||
|
3. EPD `{02,00,00}`(`020000`)
|
||||||
|
4. 数据帧循环:`[0x03, plane, offHi, offLo, ≤240B]`,plane:BW 平面 `0xFF`、红平面 `0x00`,offset 大端(c8_f.java:246-270)
|
||||||
|
5. EPD `{01,01}`(`0101`)
|
||||||
|
6. RXTX `$IMG(0,0)` → RXTX `e2`
|
||||||
|
|
||||||
|
`d8.e`("轻舞/时光"固件,d8_e.java:92-170):
|
||||||
|
1. EPD `8001` → `0000` → `020000`
|
||||||
|
2. 同样的 0x03 数据帧(240B/块,ff=BW、00=红)
|
||||||
|
3. EPD `0101` → delay(300ms) → RXTX `e2`(普通)或 `aa`(**isCalendar 预设为真时**,d8_e.java:150-167,g8.c.f=isCalendar)
|
||||||
|
|
||||||
|
与 Web 对照:分块 240B、平面码 ff/00、offset 大端 ✅ 一致;**帧头恒为 0x03,APK 没有 Web 的 0x04 小图帧**;首尾帧不同(Web:`00{cmd}` 开头 / `01` 或 `AA` 结尾;APK:`80xx`+`0000`+`020000` 开头 / `0101` 结尾 + RXTX `e2`/`aa`)。三色 = 两个平面依次发送 ✅。
|
||||||
|
|
||||||
|
### 像素打包(与 Web 完全同源)
|
||||||
|
|
||||||
|
- `a.b.h()`(a_b.java:866-914):行主序 MSB-first 8px/B;BW 平面 `R>0&&G>0&&B>0→1`,红平面 `R>0&&G==0&&B==0→1` —— = Web canvas2bytes。
|
||||||
|
- `a.b.i()`(a_b.java:916-963):列主序 x 从 W-1→0 —— = Web canvas2bytes_bw(296/250/212 宽屏用)。
|
||||||
|
- `d8.f.o()`(d8_f.java:39-96):d8/f 家族自带打包:宽 212/250/296 走列主序否则行主序;BW 平面判定为 **`R>0→1`**(d8_f.java:119-133,不含 G/B 检查;对抖动后的纯色调色板等价,但红像素在 BW 平面也会置 1,与 a.b.h 不同)。
|
||||||
|
- 红色判定 `g8.f.e()`:`r > g*1.5 && r > b*1.5 && r > threshold`(g8_f.java:45-53,float 1.5)—— = Web timedjs.js:255。
|
||||||
|
- 灰度 `g8.f.d()`:0.299/0.587/0.114(g8_f.java:40-43)。
|
||||||
|
- Bayer 4x4 矩阵 [[15,135,45,165],[195,75,225,105],[60,180,30,150],[240,120,210,90]](g8_f.java:5-8)—— 与 Web 逐值一致。
|
||||||
|
|
||||||
|
### 抖动算法
|
||||||
|
|
||||||
|
`g8.a` 枚举:NONE / FLOYD_STEINBERG / **ATKINSON(默认,g8.a.d)** / BAYER / STUCKI / JARVIS(g8_a.java:10-16)——与 Web 同 6 种;默认 Atkinson 与 Web 默认 bwr_atkinson 对应。配置 `g8.d`(ImageConfig):亮度/对比度/饱和度/diffusion/threshold(默认128)/旋转角度/scaleMode/isThreeColor(g8_d.java:229-250)。
|
||||||
|
预设 `g8.c` = FirmwareImageUploadPreset(screenSize, uploadWidthPx, uploadHeightPx, useRotatedScan, singleChannel, isCalendar)——**上传参数由固件型号预设驱动**,默认 400x300(g8_d.java:102-130)。
|
||||||
|
|
||||||
|
倒计时文字位图渲染 `f8.a.a()`:Android Canvas 绘制(DEFAULT_BOLD、fakeBold、抗锯齿、居中),按阈值二值化(f8_a.java:10-56)。
|
||||||
|
|
||||||
|
## d. 激活流程 ★(Web 端没有的关键信息)
|
||||||
|
|
||||||
|
**APK 的激活完全是 App 本地校验,算法在 libapp.so(Rust)里。**
|
||||||
|
|
||||||
|
JNI 接口 `qbsg.top.activation.ActivationManager`(decomp/qbsg_top_activation_ActivationManager.java):
|
||||||
|
|
||||||
|
```java
|
||||||
|
static { System.loadLibrary("app"); }
|
||||||
|
public final native boolean nativeActivate(String code);
|
||||||
|
public final native boolean nativeChecksumOk(String code);
|
||||||
|
public final native String nativeDecryptSegment(String hex);
|
||||||
|
public final native String nativeGetDeviceId();
|
||||||
|
public final native void nativeInit(Object ctx);
|
||||||
|
```
|
||||||
|
|
||||||
|
libapp.so 导出符号(readelf .dynstr):
|
||||||
|
- `Java_qbsg_top_activation_ActivationManager_native{Init,GetDeviceId,ChecksumOk,Activate,DecryptSegment}`
|
||||||
|
- `Java_io_github_bszapp_dzjq42_utils_SignatureUtils_getNativeSignature`(签名防伪校验)
|
||||||
|
- 字符串证据:引用 `android/provider/Settings$Secure`、`android_id`、`getContentResolver()`(**设备 ID 源自 ANDROID_ID**);rust-secp256k1(`pubkey != NULL`、`SECP256K1_FLAGS_BIT_COMPRESSION`)、sha2-0.10.9、aes-0.8.4 crate;运行时错误串 `[not activated]`、`[decrypt error: too short]`。
|
||||||
|
|
||||||
|
调用链(MainActivity.onCreate,qbsg_top_MainActivity.java:19-35):
|
||||||
|
1. `nativeInit(context)` → `nativeGetDeviceId()` 写入 StateFlow(UI 展示设备 ID)。
|
||||||
|
2. 读 SharedPreferences `activation_code`;非空且 `nativeChecksumOk(code)` 为真 → `nativeActivate(code)` → 置"已激活"全局标志(`w7.a.h.b.c`)。
|
||||||
|
3. `y7.a.a(code, fromQr)`(y7_a.java:21-61):手动/扫码激活入口,成功则持久化 `activation_code`;失败提示"激活码无效或不属于本设备"。
|
||||||
|
4. 扫码激活 `y7.a.b(bytes)`(y7_a.java:63-110):QR 原始字节 ≥71、前 4 字节 ASCII `"qbsg"`,取**最后 67 字节**作为激活码字符串 → `nativeActivate`。→ **激活码为 67 字符**。
|
||||||
|
5. `s0.u0` case 13(s0_u0.java:82-99):`nativeDecryptSegment` 仅在"已激活且处于特定模式"时可用——用于解密 §e 的两段密文 UUID。
|
||||||
|
|
||||||
|
密码学形态推断(基于 crate 组合,未做指令级逆):secp256k1(很可能 ECDSA/Schnorr 公钥验证,私钥在签发方)+ SHA-256 + AES(DecryptSegment 用)。"不属于本设备"说明激活码与 `nativeGetDeviceId()`(ANDROID_ID 派生)绑定。**这取代了 Web 的服务端 key_generator.php 模式**:Web 发 `EF`+激活码给设备固件校验;APK 在 App 侧本地校验,不给设备发任何激活命令。
|
||||||
|
|
||||||
|
另有**服务端激活 API**(面向"换固件"付费场景,与本地码并行):
|
||||||
|
- `POST https://qbsg.top/api/activation/activate`,JSON `{token, provider_id, firmware_id, mac: "mac1,mac2,…"}`(a8_f.java:529-545,`k8.h.c` = okhttp POST application/json,k8_f.java)。
|
||||||
|
- `POST https://qbsg.top/api/activation/check`,body 为设备 ID 字符串(a9_c2.java:62/80/98)。
|
||||||
|
- `POST https://qbsg.top/api/activation/firmware-list`(b9_g0.java:127)。
|
||||||
|
- 文案佐证:"Switching firmware requires paid activation."、"以下 %s 台设备未找到激活码"、"BatchActivationFirmwareOption(id=…"、"Batch firmware upgrades and sequential commands"(批量固件升级+顺序命令)。
|
||||||
|
|
||||||
|
## e. 固件 OTA / SUOTA ★(Web 端完全没有)
|
||||||
|
|
||||||
|
APK 有完整 OTA 体系,两条路径同一协议核心:
|
||||||
|
|
||||||
|
### 路径 1:独立 SUOTA 管理器 `z7.b0`(扫描 NRF- 设备→专连升级,支持批量/并发 Semaphore)
|
||||||
|
|
||||||
|
流程(z7_b0.java:655-1720,超时:连接 30s、写 8s、状态等待 60s):
|
||||||
|
1. 连接 + 服务发现,必须存在服务 `0000fef5`(否则"设备不支持SUOTA服务")。
|
||||||
|
2. 兼容探测 SERV_STATUS `64b4e8b5`;另探测 `42c3dfdd`/`b7de1eea`/`61c8849c` 备选特征。
|
||||||
|
3. `requestMtu(23)`(默认;若已协商更高则用 min(20, mtu-3) 作写块),`requestHighPriority()`。
|
||||||
|
4. SERV_STATUS 开 notify(CCCD 写 `{01,00}`,z7.k0.a)。
|
||||||
|
5. 写 MEM_DEV `8082caa8` ← `g(318767104)` = 4B 小端 `{00,00,00,13}`(mem type 0x13=SPI Flash;`z7.b0.c()` 恒返回该值,z7_b0.java:426-430)。
|
||||||
|
6. 写 GPIO_MAP `724249f0` ← `g(84280064)` = `{00,00,06,05}`(对应 UploadConfig 默认 misoGpio=5, mosiGpio=6, csGpio=3, sckGpio=0, i2cAddress=0x50,z7_o0.java toString)。
|
||||||
|
7. 镜像封装 `z7.h0`(z7_h0.java:7-34):**固件字节末尾追加 1 字节全镜像 XOR 校验**;块长 b = max(240, chunk)(默认 240B),写块长 c = MTU-3(默认 20B)。
|
||||||
|
8. 每块:写 PATCH_LEN `9d84b9a3` ← `f(blockLen)`(2B 小端)→ 分 20B 无响应写 PATCH_DATA `457871e8` → 等 SERV_STATUS notify == 2,否则报"块N失败: …"。
|
||||||
|
9. 全部块后写 MEM_DEV ← `g(-33554432)` = `{00,00,00,FE}`(END_SIGNAL)→ 等最终 SERV_STATUS==2(设备侧 XOR/CRC 校验,失败报"CRC校验失败")。
|
||||||
|
10. 写 MEM_DEV ← `g(-50331648)` = `{00,00,00,FD}`(重启设备)。
|
||||||
|
|
||||||
|
SERV_STATUS 错误码表(z7_k0.java:8-28,中文文案):2=操作成功 3=强制退出SPOTA 4=Patch Data CRC失败 5=长度与PATCH_LEN不符 6=外部存储写失败 7=内部存储不足 8=无效存储器类型 9=应用层错误 17=无效镜像Bank 18=无效镜像头部 19=无效镜像大小 20=无效产品头部 21=相同镜像错误 22=外部存储读失败 65535=通信错误 65534=设备不支持SUOTA。
|
||||||
|
|
||||||
|
### 路径 2:会话内 OTA `a8.g.f(byte[] firmware, String, GattOtaChannel)`(a8_g.java:94-…)
|
||||||
|
|
||||||
|
同一协议核心(fef5 服务探测、MTU 默认 23→20B 块、z7.h0 240B 块封装、PATCH_LEN/PATCH_DATA、SERV_STATUS 轮询),经 `BleManager$AndroidGattOtaChannel` 在已连接会话上执行。
|
||||||
|
|
||||||
|
### 固件来源与目录
|
||||||
|
|
||||||
|
- `POST https://qbsg.top/api/firmware/catalog` → 固件目录(b9_g0.java:118);`POST /api/activation/firmware-list` → 已购固件列表(b9_g0.java:127)。
|
||||||
|
- 目录项字段:firmwareId / firmwareFileName / firmwareCrc / firmwareTotalBytes / downloadUrl(strings-clean.txt:2247-2253、21021-21023;b1_m 同构解析逻辑)。
|
||||||
|
- 下载 `k8.h.b(url,…)` 流式写本地缓存(a8_f.java:396-435),`.img` 后缀(strings:3035)。**OTA 用固件从 qbsg.top 服务器下载,与 Web 端 4638e813e4.qicp.vip:8889/GuJian/file_down.php 不同源**。
|
||||||
|
- OTA 前还有 FirmwareImageUploadPreset 驱动的"升级画面"图片上传(g8.c)。
|
||||||
|
|
||||||
|
### 激活门控的加密 UUID
|
||||||
|
|
||||||
|
`z7.b0` 构造时(z7_b0.java:50-51)调 `s0.u0(13)` → `ActivationManager.nativeDecryptSegment` 解密两段 128-hex 密文:
|
||||||
|
- `a232499efa74dd64…d01d` → `this.e`
|
||||||
|
- `3fd29fea11187d48…7791` → `this.f`
|
||||||
|
|
||||||
|
字段初始为全零 UUID(`00000000-0000-0000-8000-000000000000`),**未激活时解密返回空串**(s0_u0.java:89-97 的门控逻辑)。两段明文应为 UUID(类静态块编译了 UUID 正则)。这是反盗版设计:部分 OTA 能力(大概率是某自定义服务/特征 UUID 或厂商私有权鉴)只对已激活 App 可用。
|
||||||
|
|
||||||
|
### App 自更新(与设备固件无关)
|
||||||
|
|
||||||
|
`POST https://qbsg.top/api/check-update`,body `{machine: <设备ID>, version: 4, platform: "android"}` → `{isLatest, latestVersionCode, latestVersionName, downloadUrl, updates:[{versionName,changelog}]}`(b1_m.java:215-245);下载 APK 后借 FileProvider + REQUEST_INSTALL_PACKAGES 安装("下载完成,点击安装")。
|
||||||
|
|
||||||
|
## f. 网络依赖清单(本地化去云清单)
|
||||||
|
|
||||||
|
DEX 中全部 URL(strings-clean.txt 提取,**无 mspppclock.tech、无 4638e813e4.qicp.vip**——Web 端两个服务器在 APK 均不存在):
|
||||||
|
|
||||||
|
| 端点 | 用途 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| `https://qbsg.top/api/activation/activate` | 固件激活(付费换固件) | a8_f.java:541 |
|
||||||
|
| `https://qbsg.top/api/activation/check` | 激活状态查询 | a9_c2.java |
|
||||||
|
| `https://qbsg.top/api/activation/firmware-list` | 已购固件列表 | b9_g0.java:127 |
|
||||||
|
| `https://qbsg.top/api/firmware/catalog` | 固件目录 | b9_g0.java:118 |
|
||||||
|
| `https://qbsg.top/api/check-update` | App 自更新 | b1_m.java:217 |
|
||||||
|
| `https://qbsg.top/api/fonts` | 云端字体 | strings |
|
||||||
|
| `https://qbsg.top/api/pictures`(+`/thumbnail?name=`)、`https://qbsg.top/Pictures/` | 云图库 | strings |
|
||||||
|
| `https://qbsg.top/api/templates/community/{list,download,downloaded,like,unlike}`、`/api/templates/{submit,my-list,preview,delete,tags,tmp-upload?token=}` | 模板社区 | strings |
|
||||||
|
| `https://qbsg.top/api/user/{info,logout}`、`https://qbsg.top/#/loginonly`、`/#/me?logintoken=` | 账号体系(AccountManager、网页登录 token) | strings、AccountScreen$submitToken |
|
||||||
|
| 营销外链 | QQ 群 280372025、拼多多店铺 mall_id=214155601、闲鱼 userid=355198804 | strings |
|
||||||
|
|
||||||
|
HTTP 栈:okhttp3,`k8.h`(POST JSON `k8.h.c`、下载 `k8.h.b`)。Web 端协议报告的激活站 `key_generator.php` 与固件站 `file_down.php` 在 APK 中均无迹。
|
||||||
|
|
||||||
|
## g. 与 Web 端差异汇总
|
||||||
|
|
||||||
|
| # | 差异 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **激活机制根本不同** | Web:设备 ID(EFEF 14B)→qicp.vip key_generator.php→`EF{码}` 发设备;APK:App 本地 libapp.so(Rust, secp256k1+SHA-256+AES) 校验 67 字符码(QR: "qbsg"+67B),绑定 ANDROID_ID,不发任何激活命令给设备 |
|
||||||
|
| 2 | **OTA 仅 APK 有** | 完整 Dialog SPOTA/SUOTA(fef5 服务、240B 块+20B 写块、XOR 尾字节、FE/FD 收尾),固件来自 qbsg.top;Web 无任何 OTA |
|
||||||
|
| 3 | **图像首尾帧不同** | Web:`00{cmd}` 起、`01`/`AA`(EPD) 止;APK:`8000/8001`+`0000`+`020000` 起、`0101`(EPD)+`e2`/`aa`(RXTX) 止,c8/g 家族还发 ASCII `$IMG(0,0)`;APK 恒用 0x03 帧头(无 Web 的 0x04 小图帧) |
|
||||||
|
| 4 | **Web 独有的设备功能** | WiFi 配置、EFEF 设备 ID、2300/2301、14/15、e3/e4/e5、E6 LUT、FB 休眠、e901/EA000 轮播、EA0x 槽位——APK 全部未实现(APK 定位是"图片/模板/倒计时上传 + 固件升级"工具) |
|
||||||
|
| 5 | **APK 独有的协议细节** | 连接后 requestMtu(512);读 DIS 2A24/2A26 型号与固件版本;双固件家族(平平/扫地 vs 轻舞/时光)打包与停车牌格式分叉;`aa` 按 isCalendar 预设走 RXTX;SUOTA PATCH_LEN/DATA 使用非标准 UUID 映射(9d84b9a3 / 457871e8) |
|
||||||
|
| 6 | **服务器完全不同** | Web:4638e813e4.qicp.vip:8889(激活+固件);APK:qbsg.top 全家桶 API(激活/固件/字体/图库/模板社区/账号/自更新) |
|
||||||
|
| 7 | 一致项 | EPD/RXTX UUID、NRF- 前缀、240B 分块、ff/00 平面码、dd 时间帧、e2 收尾、e100/e101/e102 模式族、倒计时 04/0n/990/ff00/ffff 帧、6 种抖动(Bayer 矩阵逐值一致)、红色判定 r>1.5g&&r>1.5b、灰度系数 |
|
||||||
|
|
||||||
|
## 附:关键文件位置
|
||||||
|
|
||||||
|
- 反编译源码:analysis/apk/decomp/(BleManagerKt=UUID 常量、BleSession=发送层、e8_o/e8_j=命令执行器、c8_g/d8_f=模式管理器、c8_f/d8_e=图像上传、g8_f/a_b=像素与抖动、f8_a=倒计时、z7_b0/z7_h0/z7_k0=SUOTA、a8_g/a8_f=OTA 与会话、b9_g0=固件目录、b1_m=自更新、y7_a=激活入口、s0_u0=解密门控)
|
||||||
|
- native 库:analysis/apk/native/libapp.so(激活算法,Rust)、libeditor.so(模板编辑器)
|
||||||
|
- 字符串表:analysis/apk/strings-clean.txt(28367 条);关键字符串 xref:analysis/apk/xrefs-key-strings.txt
|
||||||
@@ -162,6 +162,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
sp.add_argument("--channel", default="rxtx", choices=["rxtx", "epd"])
|
sp.add_argument("--channel", default="rxtx", choices=["rxtx", "epd"])
|
||||||
sp.add_argument("--no-response", action="store_true")
|
sp.add_argument("--no-response", action="store_true")
|
||||||
|
|
||||||
|
sp = sub.add_parser("ota", help="SUOTA 固件升级(高危,需确认)")
|
||||||
|
sp.add_argument("file", help="固件 .img(pQ 头格式)")
|
||||||
|
sp.add_argument("--yes", action="store_true", help="确认执行(否则仅预检)")
|
||||||
|
|
||||||
sp = sub.add_parser("batch", help="JSONL 批量执行(单次连接,agent 编排)")
|
sp = sub.add_parser("batch", help="JSONL 批量执行(单次连接,agent 编排)")
|
||||||
sp.add_argument("file", help="JSONL 文件,每行 {\"argv\": [...]};'-' 读 stdin")
|
sp.add_argument("file", help="JSONL 文件,每行 {\"argv\": [...]};'-' 读 stdin")
|
||||||
sp.add_argument("--stop-on-error", action="store_true")
|
sp.add_argument("--stop-on-error", action="store_true")
|
||||||
@@ -235,6 +239,24 @@ async def _run_command(args, t):
|
|||||||
else:
|
else:
|
||||||
await t.write_rxtx(data, response=not args.no_response)
|
await t.write_rxtx(data, response=not args.no_response)
|
||||||
return {"sent": len(data)}
|
return {"sent": len(data)}
|
||||||
|
if c == "ota":
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
image = Path(args.file).read_bytes()
|
||||||
|
# 独立预检(pQ 头 + CRC32(body)),与 tools/fw.py 同规则
|
||||||
|
if len(image) < 64 or image[0:2] != b"pQ":
|
||||||
|
raise ValueError("固件格式错误(缺 pQ 头)")
|
||||||
|
body_size = int.from_bytes(image[4:8], "little")
|
||||||
|
crc_ok = (zlib.crc32(image[64:64 + body_size]) & 0xFFFFFFFF
|
||||||
|
== int.from_bytes(image[8:12], "little"))
|
||||||
|
if not crc_ok:
|
||||||
|
raise ValueError("固件 CRC 校验失败,拒绝烧录")
|
||||||
|
if not args.yes:
|
||||||
|
return {"precheck": "ok", "body_size": body_size,
|
||||||
|
"hint": "加 --yes 执行烧录"}
|
||||||
|
progress_log = []
|
||||||
|
result = await t.run_ota(image, on_progress=progress_log.append)
|
||||||
|
return {"ota": "done", **result, "blocks_ok": len(progress_log)}
|
||||||
raise ValueError(f"未知命令 {c}")
|
raise ValueError(f"未知命令 {c}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""OTA/SUOTA(Dialog SPOTA 自定义 UUID 映射)。
|
||||||
|
证据:analysis/apk/apk-analysis.md §e(APK z7.b0/a8.g 反编译)。
|
||||||
|
流程:fef5 服务探测 → MTU/高优先级 → SERV_STATUS notify → MEM_DEV←SPI
|
||||||
|
→ GPIO_MAP → 240B 块(PATCH_LEN + 20B 写块 PATCH_DATA + 等状态2)
|
||||||
|
→ END_SIGNAL → 等状态2 → REBOOT_SIGNAL。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# GATT(自定义映射,与 Dialog 标准不同 —— 证据 z7_b0.java:665-679)
|
||||||
|
SPOTA_SERVICE_UUID = "0000fef5-0000-1000-8000-00805f9b34fb"
|
||||||
|
SPOTA_SERV_STATUS_UUID = "64b4e8b5-0de5-401b-a21d-acc8db3b913a"
|
||||||
|
SPOTA_MEM_DEV_UUID = "8082caa8-41a6-4021-91c6-56f9b954cc34"
|
||||||
|
SPOTA_GPIO_MAP_UUID = "724249f0-5ec3-4b5f-8804-42345af08651"
|
||||||
|
SPOTA_PATCH_DATA_UUID = "457871e8-d516-4ca1-9116-57d0b17b9cb2"
|
||||||
|
SPOTA_PATCH_LEN_UUID = "9d84b9a3-000c-49d8-9183-855b673fda31"
|
||||||
|
|
||||||
|
BLOCK_SIZE = 240 # z7.h0 默认块长
|
||||||
|
WRITE_CHUNK = 20 # MTU 23 → 20B 写块
|
||||||
|
|
||||||
|
MEM_DEV_SPI_FLASH = bytes([0x00, 0x00, 0x00, 0x13]) # mem type 0x13=SPI Flash
|
||||||
|
GPIO_MAP_DEFAULT = bytes([0x00, 0x00, 0x06, 0x05]) # miso5/mosi6/cs3/sck0 编码
|
||||||
|
END_SIGNAL = bytes([0x00, 0x00, 0x00, 0xFE])
|
||||||
|
REBOOT_SIGNAL = bytes([0x00, 0x00, 0x00, 0xFD])
|
||||||
|
|
||||||
|
STATUS_OK = 2
|
||||||
|
STATUS_ERRORS = {
|
||||||
|
3: "强制退出 SPOTA",
|
||||||
|
4: "Patch Data CRC 失败",
|
||||||
|
5: "长度与 PATCH_LEN 不符",
|
||||||
|
6: "外部存储写失败",
|
||||||
|
7: "内部存储不足",
|
||||||
|
8: "无效存储器类型",
|
||||||
|
9: "应用层错误",
|
||||||
|
17: "无效镜像 Bank",
|
||||||
|
18: "无效镜像头部",
|
||||||
|
19: "无效镜像大小",
|
||||||
|
20: "无效产品头部",
|
||||||
|
21: "相同镜像错误",
|
||||||
|
22: "外部存储读失败",
|
||||||
|
0xFFFF: "通信错误",
|
||||||
|
0xFFFE: "设备不支持 SUOTA",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OtaError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def xor_checksum(data: bytes) -> int:
|
||||||
|
"""全镜像 XOR 折叠为 1 字节(z7.h0 追加的尾校验)。"""
|
||||||
|
acc = 0
|
||||||
|
for b in data:
|
||||||
|
acc ^= b
|
||||||
|
return acc
|
||||||
|
|
||||||
|
|
||||||
|
def package_image(image: bytes, block_size: int = BLOCK_SIZE) -> list[bytes]:
|
||||||
|
"""镜像 + 尾 XOR 校验字节 → 240B 块列表。"""
|
||||||
|
payload = image + bytes([xor_checksum(image)])
|
||||||
|
return [payload[i:i + block_size] for i in range(0, len(payload), block_size)]
|
||||||
|
|
||||||
|
|
||||||
|
def patch_len_frame(block_len: int) -> bytes:
|
||||||
|
return block_len.to_bytes(2, "little")
|
||||||
|
|
||||||
|
|
||||||
|
def write_chunks(block: bytes, chunk: int = WRITE_CHUNK):
|
||||||
|
for i in range(0, len(block), chunk):
|
||||||
|
yield block[i:i + chunk]
|
||||||
|
|
||||||
|
|
||||||
|
async def run_ota(client, image: bytes, on_progress=None, status_timeout=60.0):
|
||||||
|
"""在已连接的 BleakClient 上执行完整 SUOTA。on_progress(dict) 回报进度。
|
||||||
|
client 为 bleak.BleakClient(需已连接且存在 fef5 服务)。"""
|
||||||
|
import asyncio
|
||||||
|
from bleak.exc import BleakError
|
||||||
|
|
||||||
|
if SPOTA_SERVICE_UUID not in [str(s.uuid) for s in client.services]:
|
||||||
|
raise OtaError("设备不存在 fef5 SUOTA 服务")
|
||||||
|
|
||||||
|
status_q: asyncio.Queue[int] = asyncio.Queue()
|
||||||
|
|
||||||
|
def _on_status(_sender, data: bytearray):
|
||||||
|
status_q.put_nowait(int.from_bytes(bytes(data), "little"))
|
||||||
|
|
||||||
|
async def wait_ok(what: str):
|
||||||
|
try:
|
||||||
|
status = await asyncio.wait_for(status_q.get(), status_timeout)
|
||||||
|
except asyncio.TimeoutError as e:
|
||||||
|
raise OtaError(f"等待设备状态超时({what})") from e
|
||||||
|
if status != STATUS_OK:
|
||||||
|
raise OtaError(f"{what} 失败: {STATUS_ERRORS.get(status, f'未知状态 {status}')}")
|
||||||
|
|
||||||
|
await client.start_notify(SPOTA_SERV_STATUS_UUID, _on_status)
|
||||||
|
try:
|
||||||
|
await client.write_gatt_char(SPOTA_MEM_DEV_UUID, MEM_DEV_SPI_FLASH, response=True)
|
||||||
|
await client.write_gatt_char(SPOTA_GPIO_MAP_UUID, GPIO_MAP_DEFAULT, response=True)
|
||||||
|
blocks = package_image(image)
|
||||||
|
for idx, block in enumerate(blocks, 1):
|
||||||
|
await client.write_gatt_char(SPOTA_PATCH_LEN_UUID,
|
||||||
|
patch_len_frame(len(block)), response=True)
|
||||||
|
for chunk in write_chunks(block):
|
||||||
|
await client.write_gatt_char(SPOTA_PATCH_DATA_UUID, chunk, response=False)
|
||||||
|
await wait_ok(f"块 {idx}/{len(blocks)}")
|
||||||
|
if on_progress:
|
||||||
|
on_progress({"block": idx, "total": len(blocks),
|
||||||
|
"bytes_sent": sum(len(b) for b in blocks[:idx])})
|
||||||
|
await client.write_gatt_char(SPOTA_MEM_DEV_UUID, END_SIGNAL, response=True)
|
||||||
|
await wait_ok("镜像校验")
|
||||||
|
await client.write_gatt_char(SPOTA_MEM_DEV_UUID, REBOOT_SIGNAL, response=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await client.stop_notify(SPOTA_SERV_STATUS_UUID)
|
||||||
|
except BleakError:
|
||||||
|
pass
|
||||||
|
return {"blocks": len(blocks), "bytes": len(image) + 1}
|
||||||
@@ -89,3 +89,8 @@ class BLETransport:
|
|||||||
|
|
||||||
async def delay(self, seconds: float):
|
async def delay(self, seconds: float):
|
||||||
await asyncio.sleep(seconds)
|
await asyncio.sleep(seconds)
|
||||||
|
|
||||||
|
async def run_ota(self, image: bytes, on_progress=None):
|
||||||
|
"""SUOTA 固件升级(证据 analysis/apk/apk-analysis.md §e)。"""
|
||||||
|
from . import ota
|
||||||
|
return await ota.run_ota(self._client, image, on_progress)
|
||||||
@@ -191,3 +191,18 @@ class TestBatch:
|
|||||||
code, out = run_cli(capsys, ["--json", "batch", str(f), "--stop-on-error"])
|
code, out = run_cli(capsys, ["--json", "batch", str(f), "--stop-on-error"])
|
||||||
results = out["data"]["results"]
|
results = out["data"]["results"]
|
||||||
assert len(results) == 1 and results[0]["ok"] is False
|
assert len(results) == 1 and results[0]["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestOta:
|
||||||
|
def test_ota_precheck_without_yes(self, capsys):
|
||||||
|
code, out = run_cli(capsys, ["--json", "ota", "app/firmware-PP_da14585_4.2_CH.img"])
|
||||||
|
assert code == 0
|
||||||
|
assert out["data"]["precheck"] == "ok"
|
||||||
|
assert out["data"]["body_size"] == 72164
|
||||||
|
|
||||||
|
def test_ota_rejects_bad_file(self, capsys, tmp_path):
|
||||||
|
bad = tmp_path / "bad.img"
|
||||||
|
bad.write_bytes(b"\x00" * 128)
|
||||||
|
code, out = run_cli(capsys, ["--json", "ota", str(bad)])
|
||||||
|
assert code == 1
|
||||||
|
assert out["ok"] is False
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""OTA/SUOTA 打包逻辑测试(证据:analysis/apk/apk-analysis.md §e,z7.h0/z7.b0)。"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ppclock import ota
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackaging:
|
||||||
|
def test_xor_checksum(self):
|
||||||
|
assert ota.xor_checksum(bytes([0x01, 0x02, 0x03])) == 0x00
|
||||||
|
assert ota.xor_checksum(bytes([0xFF, 0x0F])) == 0xF0
|
||||||
|
assert ota.xor_checksum(b"") == 0
|
||||||
|
|
||||||
|
def test_package_appends_xor_byte(self):
|
||||||
|
img = bytes([0xAA] * 240)
|
||||||
|
blocks = ota.package_image(img)
|
||||||
|
total = b"".join(blocks)
|
||||||
|
assert len(total) == 241
|
||||||
|
assert total[-1] == 0x00 # 240×0xAA(偶数个)XOR = 0
|
||||||
|
assert ota.xor_checksum(total) == 0 # 含校验字节后全镜像 XOR 为 0
|
||||||
|
|
||||||
|
def test_package_xor_byte_odd(self):
|
||||||
|
img = bytes([0xAA] * 241)
|
||||||
|
total = b"".join(ota.package_image(img))
|
||||||
|
assert total[-1] == 0xAA # 奇数个
|
||||||
|
assert ota.xor_checksum(total) == 0
|
||||||
|
|
||||||
|
def test_block_size_default_240(self):
|
||||||
|
blocks = ota.package_image(bytes(240 * 3 + 10))
|
||||||
|
assert [len(b) for b in blocks] == [240, 240, 240, 11]
|
||||||
|
|
||||||
|
def test_patch_len_encoding(self):
|
||||||
|
assert ota.patch_len_frame(240) == (240).to_bytes(2, "little")
|
||||||
|
|
||||||
|
def test_signals(self):
|
||||||
|
assert ota.MEM_DEV_SPI_FLASH == bytes([0x00, 0x00, 0x00, 0x13])
|
||||||
|
assert ota.GPIO_MAP_DEFAULT == bytes([0x00, 0x00, 0x06, 0x05])
|
||||||
|
assert ota.END_SIGNAL == bytes([0x00, 0x00, 0x00, 0xFE])
|
||||||
|
assert ota.REBOOT_SIGNAL == bytes([0x00, 0x00, 0x00, 0xFD])
|
||||||
|
|
||||||
|
def test_write_chunk_split(self):
|
||||||
|
"""240B 块按 MTU-3=20B 写块切分"""
|
||||||
|
block = bytes(range(240))
|
||||||
|
chunks = list(ota.write_chunks(block, 20))
|
||||||
|
assert len(chunks) == 12
|
||||||
|
assert b"".join(chunks) == block
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusCodes:
|
||||||
|
def test_status_ok(self):
|
||||||
|
assert ota.STATUS_OK == 2
|
||||||
|
|
||||||
|
def test_error_table_has_crc_error(self):
|
||||||
|
assert 4 in ota.STATUS_ERRORS
|
||||||
|
assert "CRC" in ota.STATUS_ERRORS[4]
|
||||||
Reference in new issue
Block a user