From 6615696c2de7955c14327ce46fadbec33f69a4ef Mon Sep 17 00:00:00 2001 From: agent Date: Fri, 24 Jul 2026 11:53:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(ppclock):=20=E7=A6=BB=E7=BA=BF=E6=BF=80?= =?UTF-8?q?=E6=B4=BB=20keygen=EF=BC=88ror2=20=E5=85=AC=E5=BC=8F+EFEF=20?= =?UTF-8?q?=E5=8F=8D=E8=A7=A3=20MAC=EF=BC=89+=20activate=20--auto=EF=BC=9B?= =?UTF-8?q?docs=20=E7=BB=88=E7=A8=BF=EF=BC=88firmware-analysis/protocol/PR?= =?UTF-8?q?OGRESS=EF=BC=89=EF=BC=8C132=20=E6=B5=8B=E8=AF=95=E5=85=A8?= =?UTF-8?q?=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 54 ++++++-- docs/firmware-analysis.md | 283 ++++++++++++++++++++++++++++++++++++++ docs/protocol.md | 18 +-- src/ppclock/cli.py | 10 +- src/ppclock/commands.py | 12 ++ src/ppclock/protocol.py | 38 +++++ tests/test_cli.py | 25 ++++ tests/test_keygen.py | 49 +++++++ 8 files changed, 465 insertions(+), 24 deletions(-) create mode 100644 docs/firmware-analysis.md create mode 100644 tests/test_keygen.py diff --git a/PROGRESS.md b/PROGRESS.md index 2f31bcb..3415941 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,21 +1,45 @@ # 进度日志 (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 互证 +### 三线逆向关键结论 -### 阻塞/风险 -- 真机是否可用未知;Task 9(真机对齐)可能降级为协议仿真验证 +1. **协议**:双通道 GATT(EPD 图像+RXTX 命令),全部 opcode 见 docs/protocol.md §4。 +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 工具链;文档终稿 diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md new file mode 100644 index 0000000..4681d3b --- /dev/null +++ b/docs/firmware-analysis.md @@ -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 | - | diff --git a/docs/protocol.md b/docs/protocol.md index 4dd895a..215fd7c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -132,15 +132,17 @@ ASCII UTF-8 直发:`WIFI_SSID:{ssid};WIFI_PASS:{pass};CITY:{城市代码}`( 4. 操作:模式切换(+`e2`)/ 传图(`EA0{slot}` → 数据块×N → `01`/`AA`)/ 倒计时 / WiFi。 5. 断线自动重连 ≤5 次。 -## 7. 本地化(去云)清单 +## 7. 本地化(去云)清单 —— 已全部解决 -| 云依赖 | 本地化方案 | -|---|---| -| key_generator.php 激活码 | 分析固件校验逻辑(见 docs/firmware-analysis.md),本地实现算法或固件补丁绕过 ⚠待固件分析 | -| file_down.php 固件下载 | 固件已本地存档 app/;SUOTA 由 `ppclock ota` 实现(§9) | -| qbsg.top 自助激活 | 同上激活方案 | -| 天气(CITY 代码) | 本地化=不依赖天气;CLI 提供 `wifi-config` 透传 | -| qrcodejs CDN(模板用) | CLI 用本地 qrcode 库 | +| 云依赖 | 本地化方案 | 状态 | +|---|---|---| +| 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/`;`ppclock ota` 本地升级(SUOTA 经 0x221F 服务,firmware-analysis §5) | ✅ 已实现 | +| qbsg.top 自助激活 | 同 keygen | ✅ | +| 天气(CITY 代码) | 原厂云功能,本地化范围外;`wifi` 命令仅透传。注意:**本固件 WIFI 命令为 no-op**(firmware-analysis §3.3) | ⚠ 固件未实现 | +| qrcodejs CDN(模板用) | CLI 本地 qrcode 库 | ✅ | + +**固件侧 no-op 命令**(Web 端发送但本固件版本忽略,CLI 保留前向兼容):`E9xx` 轮播间隔、`EA0x` 槽位/`EA000x` 轮播数量、WIFI 配置(firmware-analysis §3.3 分发器跳表)。 ## 9. SUOTA 固件升级(证据:APK §e) diff --git a/src/ppclock/cli.py b/src/ppclock/cli.py index d89e80d..3d4d3fb 100644 --- a/src/ppclock/cli.py +++ b/src/ppclock/cli.py @@ -151,6 +151,8 @@ def build_parser() -> argparse.ArgumentParser: sp = sub.add_parser("activate", help="激活") sp.add_argument("code", nargs="?", help="激活码 hex") 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.add_argument("ssid") @@ -225,8 +227,14 @@ async def _run_command(args, t): if c == "activate": if args.show_id: 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: - raise ValueError("缺少激活码或 --show-id") + raise ValueError("缺少激活码、--auto 或 --show-id") await C.activate(t, args.code) return None if c == "wifi": diff --git a/src/ppclock/commands.py b/src/ppclock/commands.py index 53d2b96..32fb189 100644 --- a/src/ppclock/commands.py +++ b/src/ppclock/commands.py @@ -102,6 +102,18 @@ async def activate(t, code_hex: str): 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: return await t.request_device_id() diff --git a/src/ppclock/protocol.py b/src/ppclock/protocol.py index 60890ac..ed0a39d 100644 --- a/src/ppclock/protocol.py +++ b/src/ppclock/protocol.py @@ -162,3 +162,41 @@ def parse_device_id(data: bytes) -> str: if all(0x20 <= b < 0x7F for b in data): return data.decode("ascii") 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) diff --git a/tests/test_cli.py b/tests/test_cli.py index edb6ac4..411d9c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -206,3 +206,28 @@ class TestOta: code, out = run_cli(capsys, ["--json", "ota", str(bad)]) assert code == 1 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() diff --git a/tests/test_keygen.py b/tests/test_keygen.py new file mode 100644 index 0000000..5dadcc3 --- /dev/null +++ b/tests/test_keygen.py @@ -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)