feat(ppclock): 离线激活 keygen(ror2 公式+EFEF 反解 MAC)+ activate --auto;docs 终稿(firmware-analysis/protocol/PROGRESS),132 测试全绿

This commit is contained in:
agent committed 2026-07-24 11:53:43 +00:00
1 parent bed8973551
commit 6615696c2d
8 files changed
+465 -24

No files matched your search

+39 -15
View File
@@ -1,21 +1,45 @@
# 进度日志 (PROGRESS) # 进度日志 (PROGRESS)
## 2026-07-24 ## 2026-07-24(最终状态)
### 已完成 ### 已完成(对照门禁)
- 侦察:固件=DA14585 镜像('pQ' 签名头,向量表基址 0x07FC0000,含 `H:mspppclock.tech` / `V:PP_da14585_4.2` / `WX:2549294317` / `MAC:%02X...` 字符串)
- 侦察:APK=Kotlin Compose Multiplatform(classes.dex 6.3MB + libapp.so/libeditor.so)
- 侦察:Web 上位机功能面(时钟/倒计时/传图6种抖动/6模板/睡眠/停车牌/WiFi配置/高级命令/固件下载)
- 建立 GOVERNANCE.md / PLAN.md / DECISIONS.md / 详细计划 docs/superpowers/plans/2026-07-24-eink-4.2-reverse.md
- git init + 骨架提交
### 进行中 | 门禁 | 状态 | 证据 |
- P0 工具链搭建 |------|------|------|
| G1 架构摸清 | ✅ | docs/architecture.md + docs/protocol.md + docs/firmware-analysis.md;三线互证:GATT/分块/平面/时间帧/模式族/倒计时/抖动算法 Web↔APK 一致,固件跳表逐项吻合 |
| G2 CLI 可用 | ✅ | 132 测试全绿;`--json` 固定 schema;CLI 零网络依赖(仅 BLE) |
| G3 功能对齐 | ✅(协议级) | Web 功能清单 24 项全部有 CLI 对应(见下方清单);真机验证因无设备在范围内降级为协议仿真(FakeTransport 逐帧断言) |
| G4 固件可控 | ✅(工具级) | tools/fw.py 解析/修改/重打包字节级往返 + CRC32 验证;SUOTA 全流程文档化(firmware-analysis §5)+ `ppclock ota` 实现;实机首刷待硬件 |
| G5 离线交付 | ✅ | 激活本地 keygen(activate --auto);模板本地渲染(wqy 字体);qrcode 本地库;无任何 CDN/服务器调用 |
### 下一步 ### 三线逆向关键结论
1. 安装 jadx + Python 依赖(bleak/pillow/capstone/androguard/pytest)
2. 抓取 mspppclock.tech 全部 JS,提取 BLE UUID 与命令协议
3. jadx 反编译 APK 互证
### 阻塞/风险 1. **协议**:双通道 GATT(EPD 图像+RXTX 命令),全部 opcode 见 docs/protocol.md §4。
- 真机是否可用未知;Task 9(真机对齐)可能降级为协议仿真验证 2. **激活可离线绕过**(firmware-analysis §4.3):激活码 = `ror2(((MAC[i]<<1)&0xFF)^0xEF)%101`(i=0..5),EFEF 设备 ID 可无损反解 MAC;EF 不门禁任何命令。
3. **SUOTA 可用**(§5):藏在 0x221F 服务,任务 0xFC 四消息,接收方校验 pQ 头+长度+XOR 写 Flash 0x38000 备 bank。
4. **固件 no-op 命令**:E9/EA/WIFI 在本固件版本被忽略(§3.3)。
5. **图像走 SPI Flash**:cmd 03 擦写 0x2E000 起 8 sector 顺序存 30KB 双平面;cmd 04 写 RAM(无边界检查,CLI 端保证尺寸正确)。
6. **驱动 IC**:SSD1683 兼容(400x300 三色)+ UC8151 备选;OTP 波形无 LUT 表;E6=红黑平面校准字节。
### 交付物清单
- `src/ppclock/`:CLI(protocol/image_pipeline/commands/transport/cli/templates/textbitmap/ota/keygen)
- `tools/`:fw.py / fw_info.py / fw_pack.py / check_env.py
- `docs/`:protocol.md / architecture.md / firmware-analysis.md
- `analysis/`:web 抓取与协议原始报告、apk 反编译报告与中间产物、firmware 反汇编产物
- `tests/`:132 测试全绿
### 遗留(需实机)
1. 真机功能回归(设备不在 BLE 范围内;扫到 17 台其他 BLE 设备无 NRF- 前缀)
2. SUOTA 首刷验证(建议先用仅改版本字符串的镜像,firmware-analysis §5 风险提示)
3. `--mac` 路径 activate --auto 的 NVDS 字节序(EFEF 路径无歧义,优先用)
### 会话时间线
- 08:14 目标设定(治理+规划+执行)
- 治理:GOVERNANCE.md / PLAN.md / DECISIONS.md / git 骨架
- P0:venv(bleak/pillow/pytest/capstone/androguard/pytest-asyncio/qrcode)+ Java21
- P1:Web JS 协议提取(子代理)→ APK 反编译互证(子代理)→ 固件逆向(子代理)
- P2:protocol/image/commands/transport/cli/templates/textbitmap 全 TDD 实现
- P3:batch/本地模板/ota/keygen 增强;fw 工具链;文档终稿
+283
View File
@@ -0,0 +1,283 @@
# firmware-PP_da14585_4.2_CH.img 逆向分析报告
> 目标文件:`app/firmware-PP_da14585_4.2_CH.img`,72228 字节
> 平台:Dialog DA14585(ARM Cortex-M0,仅 Thumb),SysRAM 96KB @ 0x07FC0000–0x07FD8000
> 工具:`analysis/firmware/fw_parse.py`(头解析+CRC)、`analysis/firmware/disasm.py`(capstone 线性扫描)、`analysis/firmware/find_cmds.py`(命令立即数扫描)、`analysis/firmware/fw.asm`(全量反汇编,32098 指令)
> 地址约定:文件偏移 = 映射地址 - 0x07FC0000 + 0x40(映射地址 = 文件偏移 - 0x40 + 0x07FC0000)
## 0. 结论速览
| 问题 | 结论 |
|---|---|
| 固件是否可控 | **完全可控**。BLE 命令处理无任何鉴权/加密门槛,全部命令(图像、刷新、模式、时间)连接即可用;激活只是云端生意,固件侧不锁功能 |
| 如何实现 OTA | 固件内置完整 **SPOTA 协议栈**(自定义任务 0xFC,MEM_DEV/GPIO_MAP/MEM_INFO/PATCH_DATA 四消息),接收方校验 `70 51`+`AA` 头后写入 SPI Flash 0x38000 备bank,可直接复用本镜像格式做 OTA |
| 命令解析在哪 | EPD 通道分发 @ 0x07FCBC64(handle 2/7/0xB);RXTX 命令分发器 @ **0x07FCBDE4**(0xDD–0xEF 跳表 @ 0x07FCBEEA + 0x14/0x15/0x23/0x88/0x99/0xCC/0xFA/0xFB/0xFF 直比链) |
| 墨水屏驱动 | **SSD1683 兼容**(主路径,cmd 0x01 传 0x2B 0x01=300 行、0x44/0x45 设 50×300、0x24/0x26 双平面)+ 备选 **UC8151/IL0373 兼容** 路径(state[0x1c] 切换);波形用驱动 IC 内部 OTP LUT,固件无 LUT 表 |
| 激活机制 | **本地弱哈希,可离线算出**。激活码 = 6 字节,`code[i] = rol2((MAC[i]<<1 & 0xFF) ^ 0xEF) % 101`,MAC 即设备 BD 地址(BLE 广播可见 / EFEF 应答的 14 字符设备 ID 可完整还原) |
---
## 1. 镜像格式(任务1)
### 1.1 头结构(64 字节,Dialog SUOTA/multi-image 格式)
| 偏移 | 字段 | 值 | 验证 |
|---|---|---|---|
| 0x00 | magic | `70 51`('pQ',LE 0x5170 = IMAGE_VALID) | ✓ |
| 0x02 | signature | `AA 01`(OTA 接收方校验首字节==0xAA,见 0x07FCB4F2 处 `cmp r0,#0xaa`) | ✓ |
| 0x04 | code_size | `E4 19 01 00` = 0x119E4 = **72164 = 文件长 72228 - 64** | ✓ 精确匹配 |
| 0x08 | CRC32 | `10 4D 89 6C` = 0x6C894D10 | ✓ = **标准 zlib CRC32**(poly 0xEDB88320,init/xorout 0xFFFFFFFF)over 数据区 72164 字节(fw_parse.py 实测 match=True;BZIP2/MPEG-2/POSIX 变体均不匹配) |
| 0x0C–0x3F | reserved | 0xFF,仅 0x20 处一字节为 0x00 | ✓ |
### 1.2 镜像布局图
```
文件偏移 映射地址 内容
0x000000 - 64B 镜像头('pQ' + len + CRC32)
0x000040 0x07FC0000 向量表:SP=0x07FD2B58, Reset=0x07FC0525, SysTick=0x07FC522B,
IRQ0=0x07FC221D, IRQ1=0x07FCA975(BLE), IRQ2=0x07F0271D(ROM), ...
0x0000C0~ 0x07FC0080~ 代码(Thumb)+ 内嵌字库/模板数据
~0x00C800 ~0x07FCC800 点阵字库区(大量 16px 字形位图,如 f0x00de30 起)
~0x00F900 ~0x07FCF8C0 GATT 描述符/UUID 表/消息handler表/DIS 属性表
0x010000~ 0x07FD0000~ 初始化数据区:DIS 字符串、22 个祝福词、adv 数据模板、TLV 记录
0x0119E4 0x07FD19E4 代码区结束(= code_size)
- 0x07FD19E8 BSS:全局状态结构体 state(激活码/设备ID/时间/显示参数)
- 0x07FD2B58 初始栈顶
```
SPI Flash 布局(由固件内常量推得,芯片 ≥ 512KB):
| Flash 偏移 | 用途 | 证据 |
|---|---|---|
| 0x00000 | 主固件 bank(boot ROM 加载到 SysRAM) | 标准 DA14585 boot |
| 0x2E000–0x35FFF | 显示帧缓存(8×4KB sector 擦除后顺序写,整图双平面 30000B) | cmd 03 处理 0x07FCBD0C:`movs r0,#0x17; lsls r5,r0,#0xd`=0x2E000,循环 8 次 `bl 0x07FCAB74`(sector 擦除),写函数 0x07FCAE4C |
| 0x38000 | **OTA 备 bank**:+0x00 产品头(校验 `70 52` 'pR'),+0x40 镜像头(校验 `70 51`+`AA`) | OTA 接收 0x07FCB4AA:`bl 0x07FC5AC4` 读 0xC 字节后 `cmp #0x70`/`cmp #0x52`;0x07FCB4F8 `cmp #0x70`/`cmp #0x51`、0x07FCB4F2 `cmp #0xaa` |
| 0x3F0EF+sum(MAC[0:3]) / 0x3F1DE+sum(MAC[0:6]) | 激活码写入槽(6B×2,地址由 MAC 求和混淆) | 0x07FC7C18:`ldr r1,=0x0003F0EF`(literal @0x07FC7C58);0x07FC7C28 `adds r1,#0xef`;存于 state[0x3C]/state[0x40] |
## 2. GATT 表(任务3)
### 2.1 UUID 出现位置(均小端存储)
| UUID | 文件偏移 | 映射地址 | 说明 |
|---|---|---|---|
| EPD Service `13187b10-…-83d3217d9a38` | 0x00FF0C | 0x07FCFECC | 128-bit 原始 16 字节 |
| EPD Char `4b646063-…-e65356ea82fe` | 0x00F972 | 0x07FCF932 | 128-bit,被 0x07FCFD7C 处描述符引用 |
| RXTX Service 0x1F10 | 0x00FF28(16B 项起于 0x00FF20) | 0x07FCFEE8 | 以 128-bit 蓝牙基 UUID 形式 `FB 34 9B 5F 80 00 00 80 00 10 00 00 10 1F 00 00` |
| RXTX Char 0x1F1F | 0x00F98E(项起于 0x00F982) | 0x07FCF94E | 同上形式 |
| 第三 Service 0x221F | 0x00FF38(项起于 0x00FF30) | 0x07FCFEF8 | web 端声明但未用——**疑似 SUOTA 通道** |
| Char 0x331F | 0x00F99E(项起于 0x00F992) | 0x07FCF95E | 0x1F10/0x221F 服务下的另一特征 |
| DIS 属性表(0x2800/0x2803 + 0x2A29/24/25/27/26/23/2A/50) | 0x00FF3C 起 | 0x07FCFEFC 起 | 12 字节/项 {uuid32, perm, aux},被 0x07FC7094 处注册代码引用 |
| GAP/BAS 表(0x2A00/0x2A01/0x2A19 电量 + 0x2902 CCC) | 0x00FBB0 起 | 0x07FCFB70 起 | BASS 任务消息 0x2400/0x2402 @0x07FCFBC4 |
EPD svc UUID 被 0x07FCFD64 引用、EPD char UUID 被 0x07FCFD7C 引用(GATT DB 描述符簇 0x07FCFD54–0x07FCFE68,三个服务各一簇)。
**SPOTA/SUOTA UUID(0xFEF6–0xFEFB)在镜像中不存在**(搜 f6fe/f7fe/…/fbfe 仅命中 BL 指令编码,无相邻簇),但:
- 广播数据模板含完整 16-bit 服务列表 **0x180A(DIS)+0xFEF5(SPOTA)**:0x07FD094A `05 03 0a 18 f5 fe`(证据:文件 0x01098A)
- SUOTA 功能以自定义任务 0xFC + 自定义 service(疑似 0x221F)实现,见 §5
### 2.2 应用消息 handler 表(ke_msg_handler {u32 id, u32 func})
- GAPM 表 @ 0x07FCFE68:0x0C13→0x07FC7EE5、0x0C15→0x07FC80C1、0x0C17→0x07FC7C6D、0x0C00→0x07FC7D19…
- 显示/应用任务 0xFD 表:0xFD06→0x07FC6B9D、0xFD03→0x07FC6C1D、0xFD08→0x07FC6B25、0xFD0E→0x07FC6591、0xFD05→0x07FC6C41(@0x07FCFE88 起)
- GATTC 表 @ 0x07FCFB00:0x0E08→0x07FC79F1、0x0E13→0x07FC7619、0x0E15→0x07FC7529、0x0E17→0x07FC7939、0x0E19→0x07FC7921、0x0D15→0x07FC7A51、0x0D21/0x0D22→0x07FC7C69/0x07FC7C65
- **SUOTA 任务 0xFC 表 @ 0x07FCFB50**:`FC00→0x07FCB6F9`、`FC01→0x07FCAEED`、`FC02→0x07FCB6B9`、`FC03→0x07FCB3F1`(文件 0x00FB90–0x00FBAF)
## 3. 命令解析(任务4)
### 3.1 写入口
GATT 写总入口 **0x07FCBC64**(4 个 BL 调用方:0x07FC58B6/0x07FC757E/0x07FC7784/0x07FC7AB8,均为 GAP/GATT 事件包装)。按消息 id(0xFD04/0xFD0D/0xFD0A,literal @0x07FCBDB8–0x07FCBDC0)分流;0xFD0A 按 handle 分流:
| handle | 去向 | 证据 |
|---|---|---|
| 2 | EPD 数据通道(0xAA/03/04/01) | 0x07FCBC84 `cmp r0,#2` |
| 7 | RXTX 命令分发器 0x07FCBDE4 | 0x07FCBC80 `cmp r0,#7` |
| 0x0B | 同上(第二命令特征,疑 0x331F) | 0x07FCBC7C `cmp r0,#0xb` |
写参数结构:`[r4,#2]`=handle,`[r4,#4]`=len,`[r4,#6]`=opcode,`[r4,#7..]`=payload。
### 3.2 EPD 通道(图像数据)
@ 0x07FCBC94:`ldrb r0,[r4,#6]` 后依次比 `0xAA / 3 / 4 / 1`:
| opcode | 处理 | 证据 |
|---|---|---|
| `03` | **整图**:首包擦除 Flash 0x2E000 起 8 sector,随后按 state[0x2A] 累计偏移经 0x07FCAE4C 顺序写 Flash(忽略包内 offset 与平面字节),长度=len-4 | 0x07FCBD0C–0x07FCBDB6 |
| `04` | **日历小图**:按包内 BE offset 写 RAM:`[7]==0xFF`→0x07FD4B18+off(无边界检查!);`[7]==0x00`→0x07FD0A0A+off(边界 0xAA9);数据=r4+0xA,len-4 | 0x07FCBD3C–0x07FCBD80;literal @0x07FCBDC8/0x07FCBDD8/0x07FCBDDC |
| `01` | 整图结束:state 清零 + `bl 0x07FC6E98`(Flash 落盘/刷屏准备) | 0x07FCBCA2–0x07FCBCAE |
| `AA` | 小图结束:置标志 + `bl 0x07FCA880(0)` 触发刷新编排 | 0x07FCBD82–0x07FCBD8E |
| `00` | 忽略(web 的 clearScreen 第一步是固件侧 no-op) | 0x07FCBC9A 链无匹配落 0x07FCBD02 |
### 3.3 RXTX 命令分发器 0x07FCBDE4(文件 0x00BDA4)
前置:opcode 0–4 且 len≥0x1F → 数据写(拷贝 (op&7)×32 字节到 0x07FD17BD,op 记入 0x07FD09D0);opcode 5–8 且 len≥0x1F → 写 0x07FD183D。这覆盖 web 的 `04{128B}` 倒计时前缀位图(op 4→128B ✓)。
随后 `subs r1,#0xdd; cmp r1,#0x12; bls` → **跳表 @ 0x07FCBEEA**(19 项,target=0x07FCBEEE+entry×2,表体文件 0x00BF2C):
| opcode | target | 语义(与 web 对照) |
|---|---|---|
| `DD` | 0x07FCBF12 | 设时间:LE 4 字节 unix → state[0x78],并换算;只用前 4 字节(web 的年月日周字节被忽略)✓ |
| `E1` | 0x07FCBF78 | 模式:[7]==0→图模式(0x07FCBF8C,state[2]=0+刷新);==1→日历(0x07FCC10A,循环/可指定,state[0x14]=0);==2→时钟(0x07FCC12E,state[0x14]=1)✓ |
| `E2` | 0x07FCBF64 | 提交:查显示状态后 → 0x07FCC0D0 → `bl 0x07FCA880(1)` 刷新 ✓ |
| `E3` | 0x07FCC0D0 | 反色 → `bl 0x07FCA880(0)` ✓ |
| `E4` | 0x07FCBF98 | 时钟字体 state[0x25] 0/1 循环 ✓ |
| `E5` | 0x07FCBF58 | 旋转 180°:state[0x11] 翻转 ✓ |
| `E6` | 0x07FCBF70 | LUT/VCOM 校准字节 → 显示参数结构 0x07FD09D0+6,然后刷新。平面写函数 0x07FC8648 @0x07FC86EA 读该字节并按 **0x10 分界**(<0x10 红平面 / ≥0x10 黑平面)——与 web 文档"01–0f 设红、10–f0 设黑"精确一致 ✓ |
| `EF` | 0x07FCBFAA | 激活/取设备 ID,见 §4 |
| DE–E0, E7–EE | 0x07FCC0D6 | **空操作**(含 web 在用的 `E9` 轮播间隔、`EA` 槽位——本固件忽略)✗差异 |
跳表外直比链(0x07FCBE54–0x07FCBE88):
| opcode | target | 语义 |
|---|---|---|
| `14` | 0x07FCC004 | 12/24h:state[0x23] 翻转 ✓ |
| `15` | 0x07FCC0A0 | 时钟颜色:state[0x19]^=1 ✓ |
| `23` | 0x07FCC058 | 三色/黑白:state[0x18]=[7]&1;并写参数[6]=0x0C/0x80 ✓ |
| `88` | 0x07FCC092 | 置标志位 0x8000(web 未用) |
| `99` | 0x07FCC068 | 日历倒计时文字:len≥0x1F 时按 [7]×0x48 拷贝 **72 字节/字符** 到 0x07FD18BD 基缓冲,[7]+1 记字符数 ✓(与 web `990{j}{72B}` 一致) |
| `CC` | 0x07FCBFEE | 参数[5]:>0xE0 或 ≤0x47 时钳到 0x48(web 未用) |
| `FA` | 0x07FCC04A | 关闭倒计时:state[0x24]=1,参数[2]=2 ✓ |
| `FB` | 0x07FCC010 | 休眠时段:BCD 解析 [7]=开关、[8]=HH、[9]=MM → state[0x13]/参数+3/+4 ✓ |
| `FF` | 0x07FCBE8A | len==6 时:`FF 00`→0x07FCC152 时钟倒计时日期(BCD 年×100+月+日 → 结构+0x10/14/18,state[0xD]=1);`FF FF`→0x07FCBEA0 日历倒计时日期(→ +0x1C/20/24)✓ |
**web 有而固件无**:`E9`/`EA`(no-op)、ASCII `WIFI_SSID:...`(固件无此字符串、无解析路径,直接忽略)——本固件为纯 BLE 版,WiFi/天气属其他 SKU 或云端能力。
## 4. 激活验证逻辑(任务6)——可离线绕过
### 4.1 EF 处理路径(0x07FCBFAA)
```
[r4,#7]==0xEF 且 len==2 → "EFEF":bl 0x07FCA22C 生成设备ID,
循环 r5=0x14..0x21 把 14 字节经 ke_msg 发回(notify 应答)✓ 与 web "14B 设备ID"一致
否则 → 比较循环 @0x07FCC0AA:r4[7..12](收的 6 字节) vs state[0x44..0x49]
全部相等 → 0x07FCC0DE:state+0x4A(6B)写 Flash[state[0x40]]、state+0x44(6B)写 Flash[state[0x3C]]
(写函数 0x07FCAE4C,与图像写 Flash 同一函数,证实为 SPI Flash 写)
不等 → 0x07FCC0D6 直接返回(无任何惩罚/锁定)
```
### 4.2 比较目标 state[0x44] 的来源 = MAC 哈希(关键发现)
启动初始化函数 **0x07FC23D0**(被 0x07FC7C30/0x07FC877E 调用)尾部 0x07FC24C8–0x07FC24F4,对 i=0..5 循环:
```
t = ((MAC[i]<<1) & 0xFF) ^ 0xEF ; ldrb→lsls#1→eors ~0x10→uxtb
code1[i] = ((t<<6)+(t>>2)) & 0xFF % 101 ; = rol2(t) % 0x65,经 __aeabi_uidivmod(0x07F1DFB8)
→ 存入 state[0x44+i]
```
- MAC = state[0x4A..0x4F] = 设备 BD 地址,由工厂函数 0x07FC7B94 经 NVDS/TLV 读取(0x07FCA2DC,tag 1 = Dialog NVDS BD_ADDRESS;缺省记录 @0x07FD04BA:`14 00 06 00 70 ca ea 80 d0 07…`)
- 设备 ID(EFEF 应答)生成 @ 0x07FCA22C:`snprintf(state+0x14, "%02X"*7 @0x07FCA298, |MAC4-0x99|, |MAC5-0x99|, |MAC3-0x99|, |MAC2-0x99|, |MAC1-0x99|, |MAC0-0x99|, flags)`;flags=state[0x10] = 各 MAC 字节 ≥0x9A 的位掩码(0x07FC23D4–0x07FC23F4 计算)——**14 个十六进制字符 + 掩码可无损还原 6 字节 MAC**
- state[0x3C]/[0x40](激活码 Flash 写地址)= `0x3F0EF + MAC[0]+MAC[1]+MAC[2]` 与 `0x3F1DE + sum(MAC[0..5])`(0x07FC7C0C–0x07FC7C2C,常数 literal @0x07FC7C58 = 0x0003F0EF)——按 MAC 求和混淆存放
- 全镜像**不存在从 Flash 读回激活码的代码**(Flash 读函数 0x07FC5AC4/0x07FC5A2C 仅 OTA 路径调用);0x07FC4CDC 每次刷新都会把这两个槽**清零**并按 RAM 码非零置 state[0xE]/[0xF](显示合成开关,非门锁)
### 4.3 绕过结论
1. 激活码是纯本地函数:`code[i] = rol2(((MAC[i]<<1)&0xFF)^0xEF) % 101`(i=0..5),**无需厂商服务器**。
2. MAC 获取:BLE 广播地址直接可见;或发 `EFEF` 收 14 字符 ID 按上式反解。
3. 固件侧 EF 成功/失败均不门禁任何后续命令(03/04 图传、E1/E2 刷新等在未激活状态同样解析)——**激活仅是厂商云端商业模式,本地使用可直接无视**;如需让屏幕不再显示激活页,发送一次正确 EF 即可(或直接 patch §8 所述字节)。
## 5. OTA / SUOTA(任务3+可行性)
证据链:
1. **任务 0xFC 四消息**(表 @0x07FCFB50):FC00=MEM_DEV(0x07FCB6F8)、FC01=GPIO_MAP(0x07FCAEED)、FC02=MEM_INFO(0x07FCB6B8)、FC03=PATCH_DATA(0x07FCB3F0)——与 Dialog SPOTA 语义一一对应。
2. **写转发器 0x07FC8340**:按服务内 handle 偏移(`[r4,#6]`=起始 handle,偏移 2–13 跳表 @0x07FC8392,12 项=6 特征×2)把特征写打包成 0xFC00–0xFC03 消息(0x07FC83CC:`movs r0,#0x3f;lsls r0,#0xa`=0xFC00;literal 池 @0x07FC8520–0x07FC8528 = FC02/FC03/FC01)。6 特征 = SPOTA 标准集(mem_dev/gpio_map/mem_info/patch_len/patch_data/serv_status)。
3. **接收写盘 0x07FCB3F0**:state[0x1C] 状态机(0x10–0x13);0x200 字节块缓冲(0x07FCB424 `lsls r1,#9`);XOR 校验和累计(0x07FCB448–0x07FCB458,存 state[0x24]);收满后校验缓冲头 `70 51`(0x07FCB486–0x07FCB492)→ 写 Flash;并回读 0x38000 校验产品头 `70 52`(0x07FCB4BC/0x07FCB4C4)与镜像头 `70 51 AA`(0x07FCB4F2–0x07FCB502)。
4. 广播数据声明 SPOTA 0xFEF5(0x07FD094A)。
**OTA 实施建议**:按本镜像格式(64B 'pQ' 头 + zlib CRC32)打包 → 连接后走 SUOTA 服务(web 端声明但未用的 **0x221F**,待实机确认句柄;可先 `btgatttool` 枚举含 6 特征的服务)→ MEM_DEV 写 SPI flash 基址、PATCH_DATA 分块(块内无需应用层 ACK,结束由接收方校验头+长度+XOR)→ 完成后设备复位,boot ROM 按产品头切换 bank。**风险**:bank 切换依赖 boot ROM 读 0x38000 产品头,首次实机前建议先用一份仅改版本字符串的镜像验证。
## 6. 墨水屏驱动(任务5)
低层原语:发命令 **0x07FC3646**(CS/DC=cmd)、发数据 **0x07FC33D0**、SPI 读 **0x07FC31B4**、GPIO 配置 0x07FC3658(P0/P1 基 0x50003000,literal @0x07FC3694)、SPI1 基 0x50001200(literal @0x07FC32B8 等)、延时 0x07FCB8EC。
### 6.1 驱动 IC 识别 —— 双面板支持,state[0x1C] 选择
**主路径 state[0x1C]==0:SSD1683 兼容**(面板初始化 0x07FC3440,证据 @0x07FC34C8–0x07FC3562):
```
12 ; SWRESET,延时 200ms(0x07FC34CE bl 0x07FC3154, r0=0xC8)
74 / 54 ; 模拟块控制
7E / 3B ; 数字块控制
2B / 04 63 ; VCOM/ACVCOM
0C / 8B 9C 96 0F ; Booster soft start(GDEY042 系列特征序列)
11 / 01 ; data entry mode
44 / 00 31 ; RAM X: 0–49 → 50 字节 = 400 列 ✓
45 / 2C 01 00 00 ; RAM Y: start 0x012C=300, end 0(反向寻址) ✓ 300 行
3C / 01 ; border
22 / B1, 20 ; 装载温度+主激活
4E / 00, 4F / 2C 01 ; RAM 计数器
```
另有 0x07FC3344 发 `01 / 2B 01 / (1-state[0x20])` = Driver Output Control,mux 0x012B=299 → **300 行**(400×300 三色 4.2" 实锤)。温度读取 @0x07FC32BC:`18/80`、`22/B1`、`20`、`1B` 读 2 字节 >>4 带符号(bit 0x800)→ state[0x28]。
**备选路径 state[0x1C]!=0:UC8151/IL0373 兼容**(0x07FC3476 起):
```
01 / 03 00 26 26 (0F|03) ; Power Setting(VGH/VSH/VSL/VDHR)
06 / 17 17 17 ; Booster Soft Start
50 / (97|51) 60 ; VCOM & Data Interval
22 / 04, 延时 200
```
面板类型 state[0x1C] 由 TLV 记录(tag 0x40–0x44,读取器 0x07FCA2DC 跳表 @0x07FCA2F4)配置。
### 6.2 刷新编排 0x07FCA880
读温度→选波形参数(0x07FC3440)→ 平面写 **0x07FC8648** 两次:`(0x24|0x10, 0)` 与 `(0x26|0x13, 2)`——0x24/0x26 = SSD168x 写黑/红 RAM;0x10/0x13 = UC8151 写 RAM1/RAM2,按面板标志 state[0x1E]/[0x1F] 选择 → ADC 读电池(0x07FC6374,state[0x2C]=百分比)→ 计数 state[0x2B]。
### 6.3 LUT
**固件内无 LUT 表**:全程未发 `0x32`(Write LUT),波形用驱动 IC 内部 OTP LUT;`E6` 命令仅存校准字节到 0x07FD09D0+6,平面写时按 <0x10/≥0x10 分红/黑平面生效(0x07FC86EA)。两段"LUT 候选"(0x07FD06F8/0x07FD187D)实为清零/0xFF 填充的间隙区,非 LUT。
## 7. 字符串交叉引用(任务7)
| 字符串 | 位置 | 引用点 | 用途 |
|---|---|---|---|
| `TEW MI YAO:` | 0x07FC2508 (f0x002548) | `adr` @0x07FC2414 | 开机/未激活屏第一行标题(经 0x07FCC1C4 snprintf 到显存,随 0x07FC23D0 绘制) |
| `H:mspppclock.tech` | 0x07FC251C | `adr` @0x07FC244A | 激活屏:厂商主机名(仅显示用,固件无联网代码) |
| `V:PP_da14585_4.2` | 0x07FC2530 | `adr` @0x07FC2464 | 激活屏:固件版本 |
| `WX:2549294317` | 0x07FC2544 | `adr` @0x07FC247C | 激活屏:微信联系方式 |
| `MAC:%02X%02X…` | 0x07FC2554 | `adr` @0x07FC24A0 | 激活屏:显示 BD 地址 |
| `%02X%02X%02X%02X%02X%02X%02X` | 0x07FCA298 | `adr` @0x07FCA286 | 设备 ID(14 hex)格式串,0x07FCA22C |
| `%d %02d` / `%d %02d %02d` / `%s %s` 等 | 0x07FC2D88/90/58/60 | `adr` @0x07FC2D1E/22/C4/C8 | 时钟/日历屏文本排版 |
| `Happiness`…`Motive`(22 词) | 0x07FD034C–0x07FD04B8 | 指针表 @0x07FC9708 | 日历模式每日一词 |
| `DA14585` / `PP_da14585_4.2` / `1.0.0.0-LE` / `Dialog Semi` | 0x07FD02C4/0x07FD08B9/0x07FD08D0/0x07FD08F6 | DIS 表 | DIS 0x2A24/0x2A26/0x2A27/0x2A29 值 |
| `NRF-XXXXXX` | 0x07FD0956 | 0x07FCA210、0x07FCBC4C | 广播名模板(X 由 MAC 填充;web 端 `namePrefix:'NRF-'` ✓) |
| `0123456789abcdef`/`ABCDEF` | 0x07FCC638/0x07FCC64C | hex 工具 | 通用 |
`Sun/Mon/Tues/Wednes/Thurs/Fri/Satu`(0x07FD0482 起,定长 8B)与 `中华人民共和国`(0x07FD0920,UTF-8)为日历显示素材。
## 8. 固件修改/重打包建议
1. **重打包**:改完代码区后用 `fw_parse.py` 同算法重算 zlib CRC32 写回头 0x08,长度不变则无需改 0x04;经 §5 SUOTA 通道或 UART boot 烧录。
2. **免激活 patch**:将 0x07FCC0AA 比较循环的首分支 `beq`(0x07FCC0BC 处 `beq #0x7fcc0ac` 的 fall-through `b #0x7fcc0d6`)改为直接跳成功分支,或把 EF 处理改成总写 Flash——但更简单的做法仍是按 §4.3 公式算码发送。
3. **去激活开机屏**:0x07FC23D0 的 r0 判分支(0x07FC23F6 `cmp r0,#0; beq`)强制走 0x07FC24C4 即可跳过 TEW MI YAO/MAC/WX 屏。
4. **RAM 无边界写注意**:cmd 04 的 0xFF 平面分支(0x07FCBD48)无长度检查,自研上位机时不要越界(会踩 0x07FD4B18 之后的 BSS)。
5. **手柄速查**:EPD 数据 handle 2;命令 handle 7 与 0x0B(同一解析器);SUOTA 服务=含 6 特征(12 句柄)的服务(枚举确认)。
6. **排产注意**:固件忽略 E9/EA/WIFI 命令——自研上位机不必实现;倒计时文字位图经 opcode 0–8 数据通道(len≥0x1F)而非 0x99 之外的格式。
## 附:关键地址速查
| 名称 | 映射地址 | 文件偏移 |
|---|---|---|
| 镜像头 | - | 0x000000 |
| Reset handler | 0x07FC0525 | 0x0004E5 |
| GATT 写总入口 | 0x07FCBC64 | 0x00BC24 |
| RXTX 命令分发器 | 0x07FCBDE4 | 0x00BDA4 |
| 0xDD–0xEF 跳表 | 0x07FCBEEA(表体) | 0x00BF2C |
| EF 激活处理 | 0x07FCBFAA | 0x00BF6A |
| 激活码 keygen(boot) | 0x07FC24C8 | 0x002488 |
| 设备 ID 生成 | 0x07FCA22C | 0x00A1EC |
| SUOTA PATCH_DATA 接收 | 0x07FCB3F0 | 0x00B3B0 |
| SUOTA 写转发器 | 0x07FC8340 | 0x008300 |
| 面板初始化(SSD1683/UC8151) | 0x07FC3440 | 0x003400 |
| 刷新编排 | 0x07FCA880 | 0x00A840 |
| 平面写(0x24/0x26/0x10/0x13) | 0x07FC8648 | 0x008608 |
| Flash 写(页) | 0x07FCAE4C | 0x00AE0C |
| Flash 读 | 0x07FC5AC4 / 0x07FC5A2C | 0x005A84 / 0x0059EC |
| Flash sector 擦除 | 0x07FC58E0 / 0x07FCAE8C | 0x0058A0 / 0x00AE4C |
| 全局状态 state | 0x07FD19E8(BSS) | - |
| 显示参数结构(E6 字节 +6) | 0x07FD09D0 | 0x010990 |
| cmd04 RAM 缓冲 0xFF/0x00 | 0x07FD4B18 / 0x07FD0A0A | - |
| 文字位图缓冲(op0–8/99) | 0x07FD17BD / 0x07FD183D / 0x07FD18BD | - |
+10 -8
View File
@@ -132,15 +132,17 @@ ASCII UTF-8 直发:`WIFI_SSID:{ssid};WIFI_PASS:{pass};CITY:{城市代码}`(
4. 操作:模式切换(+`e2`)/ 传图(`EA0{slot}` → 数据块×N → `01`/`AA`)/ 倒计时 / WiFi。 4. 操作:模式切换(+`e2`)/ 传图(`EA0{slot}` → 数据块×N → `01`/`AA`)/ 倒计时 / WiFi。
5. 断线自动重连 ≤5 次。 5. 断线自动重连 ≤5 次。
## 7. 本地化(去云)清单 ## 7. 本地化(去云)清单 —— 已全部解决
| 云依赖 | 本地化方案 | | 云依赖 | 本地化方案 | 状态 |
|---|---| |---|---|---|
| key_generator.php 激活码 | 分析固件校验逻辑(见 docs/firmware-analysis.md),本地实现算法或固件补丁绕过 ⚠待固件分析 | | key_generator.php 激活码 | **本地 keygen**:`code[i]=ror2(((MAC[i]<<1)&0xFF)^0xEF)%101`(firmware-analysis §4.2);`ppclock activate --auto` 经 EFEF 反解 MAC 自动完成;且固件侧 EF 不门禁任何命令 | ✅ 已实现 |
| file_down.php 固件下载 | 固件已本地存档 app/;SUOTA 由 `ppclock ota` 实现(§9) | | file_down.php 固件下载 | 固件本地存档 `app/`;`ppclock ota` 本地升级(SUOTA 经 0x221F 服务,firmware-analysis §5) | ✅ 已实现 |
| qbsg.top 自助激活 | 同上激活方案 | | qbsg.top 自助激活 | 同 keygen | ✅ |
| 天气(CITY 代码) | 本地化=不依赖天气;CLI 提供 `wifi-config` 透传 | | 天气(CITY 代码) | 原厂云功能,本地化范围外;`wifi` 命令仅透传。注意:**本固件 WIFI 命令为 no-op**(firmware-analysis §3.3) | ⚠ 固件未实现 |
| qrcodejs CDN(模板用) | CLI 用本地 qrcode 库 | | qrcodejs CDN(模板用) | CLI 本地 qrcode 库 | ✅ |
**固件侧 no-op 命令**(Web 端发送但本固件版本忽略,CLI 保留前向兼容):`E9xx` 轮播间隔、`EA0x` 槽位/`EA000x` 轮播数量、WIFI 配置(firmware-analysis §3.3 分发器跳表)。
## 9. SUOTA 固件升级(证据:APK §e) ## 9. SUOTA 固件升级(证据:APK §e)
+9 -1
View File
@@ -151,6 +151,8 @@ def build_parser() -> argparse.ArgumentParser:
sp = sub.add_parser("activate", help="激活") sp = sub.add_parser("activate", help="激活")
sp.add_argument("code", nargs="?", help="激活码 hex") sp.add_argument("code", nargs="?", help="激活码 hex")
sp.add_argument("--show-id", action="store_true", help="仅读取设备 ID") sp.add_argument("--show-id", action="store_true", help="仅读取设备 ID")
sp.add_argument("--auto", action="store_true",
help="离线自动激活:EFEF 反解 MAC + 本地 keygen(无需服务器)")
sp = sub.add_parser("wifi", help="WiFi 配置") sp = sub.add_parser("wifi", help="WiFi 配置")
sp.add_argument("ssid") sp.add_argument("ssid")
@@ -225,8 +227,14 @@ async def _run_command(args, t):
if c == "activate": if c == "activate":
if args.show_id: if args.show_id:
return {"device_id": await C.get_device_id(t)} return {"device_id": await C.get_device_id(t)}
if args.auto:
mac = None
if args.mac:
# 显示序 MAC → NVDS 字节序(LSB 在前);不确定时优先省略 --mac 走 EFEF 反解
mac = bytes.fromhex(args.mac.replace(":", ""))[::-1]
return await C.activate_auto(t, mac)
if not args.code: if not args.code:
raise ValueError("缺少激活码或 --show-id") raise ValueError("缺少激活码、--auto 或 --show-id")
await C.activate(t, args.code) await C.activate(t, args.code)
return None return None
if c == "wifi": if c == "wifi":
+12
View File
@@ -102,6 +102,18 @@ async def activate(t, code_hex: str):
await t.write_rxtx(P.simple_command("commit")) await t.write_rxtx(P.simple_command("commit"))
async def activate_auto(t, mac: bytes | None = None) -> dict:
"""离线自动激活(firmware-analysis §4.3):MAC→本地 keygen→EF 下发。
mac=None 时经 EFEF 设备 ID 反解(字节序无歧义);返回 {device_id, mac, code}。"""
device_id = None
if mac is None:
device_id = await t.request_device_id()
mac = P.recover_mac_from_device_id(device_id)
code = P.keygen_code(mac)
await activate(t, code.hex())
return {"device_id": device_id, "mac": mac.hex(), "code": code.hex()}
async def get_device_id(t) -> str: async def get_device_id(t) -> str:
return await t.request_device_id() return await t.request_device_id()
+38
View File
@@ -162,3 +162,41 @@ def parse_device_id(data: bytes) -> str:
if all(0x20 <= b < 0x7F for b in data): if all(0x20 <= b < 0x7F for b in data):
return data.decode("ascii") return data.decode("ascii")
return data.hex().upper() return data.hex().upper()
def _ror2(v: int) -> int:
"""8 位循环右移 2(证据 0x07FC24C8:(t<<6)+(t>>2) & 0xFF = ror2,非左移)。"""
return ((v << 6) + (v >> 2)) & 0xFF
def keygen_code(mac: bytes) -> bytes:
"""本地激活码:code[i] = ror2(((MAC[i]<<1)&0xFF)^0xEF) % 101,i=0..5。
证据:docs/firmware-analysis.md §4.2(keygen @0x07FC24C8,MAC=BD 地址,NVDS 顺序)。
EF 成败不门禁任何固件命令——激活仅影响屏显激活页。"""
if len(mac) != 6:
raise ValueError("MAC 必须 6 字节")
return bytes(_ror2(((b << 1) & 0xFF) ^ 0xEF) % 101 for b in mac)
def recover_mac_from_device_id(device_id: str) -> bytes:
"""14hex 设备 ID → 6B MAC。
ID = %02X(|MAC4-0x99|,|MAC5-0x99|,|MAC3-0x99|,|MAC2-0x99|,|MAC1-0x99|,|MAC0-0x99|,flags),
flags bit i = MAC[i] ≥ 0x9A(证据 §4.2 生成函数 0x07FCA22C)。"""
if len(device_id) != 14:
raise ValueError("设备 ID 必须 14 个 hex 字符")
try:
vals = [int(device_id[i:i + 2], 16) for i in range(0, 14, 2)]
except ValueError:
raise ValueError("设备 ID 含非 hex 字符") from None
*v, flags = vals
# v 顺序:MAC4,MAC5,MAC3,MAC2,MAC1,MAC0 → 反映射
order = [4, 5, 3, 2, 1, 0]
mac = [0] * 6
for vi, idx in zip(v, order):
if flags & (1 << idx):
mac[idx] = 0x99 + vi
else:
mac[idx] = 0x99 - vi
if not 0 <= mac[idx] <= 0xFF:
raise ValueError("设备 ID 无法反解(掩码与差值矛盾)")
return bytes(mac)
+25
View File
@@ -206,3 +206,28 @@ class TestOta:
code, out = run_cli(capsys, ["--json", "ota", str(bad)]) code, out = run_cli(capsys, ["--json", "ota", str(bad)])
assert code == 1 assert code == 1
assert out["ok"] is False assert out["ok"] is False
class TestActivateAuto:
def test_activate_auto_via_device_id(self, capsys):
"""EFEF 应答 ID '89794900980101' → MAC 9A:01:99:50:10:20 → keygen → EF 下发"""
class IdTransport(FakeTransport):
async def request_device_id(self, timeout=8.0):
self.rxtx_writes.append(bytes([0xEF, 0xEF]))
return "89794900980101"
FakeTransport.instances.clear()
import ppclock.cli as c
orig = c._make_transport
c._make_transport = lambda args: IdTransport(args.mac, args.timeout)
try:
code, out = run_cli(capsys, ["--json", "activate", "--auto"])
finally:
c._make_transport = orig
assert code == 0
t = IdTransport.instances[0]
assert t.rxtx_writes[0] == bytes([0xEF, 0xEF])
# keygen(9A,01,99,50,10,20):逐字节 rol2(((m<<1)&FF)^EF)%101
from ppclock.protocol import keygen_code
expected = keygen_code(bytes([0x9A, 0x01, 0x99, 0x50, 0x10, 0x20]))
assert t.rxtx_writes[1] == bytes([0xEF]) + expected
assert out["data"]["code"] == expected.hex()
+49
View File
@@ -0,0 +1,49 @@
"""激活 keygen 测试 — 向量按 docs/firmware-analysis.md §4.2 手工推算。"""
import pytest
from ppclock import protocol as P
class TestKeygen:
def test_keygen_vector(self):
"""MAC 00:11:22:33:44:55 → [49,14,32,98,15,81](ror2(((m<<1)&FF)^EF)%101 手算)"""
code = P.keygen_code(bytes([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]))
assert list(code) == [49, 14, 32, 98, 15, 81]
def test_keygen_all_zero_mac(self):
# t = 0^0xEF = 0xEF; ror2(0xEF)=0xFB=251; 251%101=49
assert list(P.keygen_code(bytes(6))) == [49] * 6
def test_keygen_all_ff_mac(self):
# t = (0x1FE & 0xFF)^0xEF = 0xFE^0xEF = 0x11; rol2(0x11)=0x44=68; 68%101=68
assert list(P.keygen_code(b"\xFF" * 6)) == [68] * 6
def test_keygen_requires_6_bytes(self):
with pytest.raises(ValueError):
P.keygen_code(b"\x01" * 5)
class TestRecoverMac:
def test_recover_vector(self):
"""MAC [9A,01,99,50,10,20] → ID '89794900980101'(|MAC4-99|,|MAC5-99|,|MAC3-99|,
|MAC2-99|,|MAC1-99|,|MAC0-99|,flags;flags=各字节≥0x9A 位掩码)"""
mac = P.recover_mac_from_device_id("89794900980101")
assert list(mac) == [0x9A, 0x01, 0x99, 0x50, 0x10, 0x20]
def test_roundtrip_all_high(self):
# 全 ≥0x9A:flags=0x3F;v=|m-0x99|
mac = bytes([0x9A, 0xB0, 0xC1, 0xFF, 0xA0, 0x9B])
vals = [abs(mac[4] - 0x99), abs(mac[5] - 0x99), abs(mac[3] - 0x99),
abs(mac[2] - 0x99), abs(mac[1] - 0x99), abs(mac[0] - 0x99)]
dev_id = "".join(f"{v:02X}" for v in vals) + "3F"
assert P.recover_mac_from_device_id(dev_id) == mac
def test_id_must_be_14_hex(self):
with pytest.raises(ValueError):
P.recover_mac_from_device_id("123")
def test_keygen_after_recover_matches(self):
"""recovered MAC → keygen:与直接对原 MAC keygen 一致"""
mac = bytes([0x9A, 0x01, 0x99, 0x50, 0x10, 0x20])
recovered = P.recover_mac_from_device_id("89794900980101")
assert P.keygen_code(recovered) == P.keygen_code(mac)