Compare commits
17
Commits
d33a1296ff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
578cd7fa20 | ||
|
|
2919ebe312 | ||
|
|
092b104e3d | ||
|
|
3db8295654 | ||
|
|
da02bd31c6 | ||
|
|
34dd6b4c52 | ||
|
|
faf8db4cbb | ||
|
|
3ce4fc0f0a | ||
|
|
4ba51e8770 | ||
|
|
a1041b9bad | ||
|
|
a1ed2c2736 | ||
|
|
8a9019a925 | ||
|
|
6a5ffda9fb | ||
|
|
161c54ad5e | ||
|
|
5130bb7aa2 | ||
|
|
f975827971 | ||
|
|
e9b919eeba |
No files matched your search
@@ -6,3 +6,4 @@ __pycache__/
|
|||||||
analysis/apk/jadx-out/
|
analysis/apk/jadx-out/
|
||||||
analysis/apk/native/
|
analysis/apk/native/
|
||||||
analysis/firmware/fw.asm
|
analysis/firmware/fw.asm
|
||||||
|
._*
|
||||||
+32
-1
@@ -1,6 +1,23 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
## [0.2.0] — 2026-07-30
|
## [Unreleased] — 2026-08-03
|
||||||
|
|
||||||
|
- 修复同型号异固件设备的控制通道选择:`HM42_AIO_*` 自动使用 Web-BLE/APK
|
||||||
|
实际采用的 `1F1F`,已验证 `PP_da14585_4.2` 保持使用 `331F`。
|
||||||
|
- 本地 BLE 连接时读取 DIS 固件版本 `2A26`;若固件未知,则以 GATT 特征是否存在
|
||||||
|
在 `331F`/`1F1F` 间安全回退。
|
||||||
|
- write-only 通道不再强行订阅通知;设备状态新增 `firmware_revision` 与
|
||||||
|
`rxtx_uuid`,可直接审计当前固件档案与控制通道。
|
||||||
|
- HP 真机 `HM42_AIO_V1.0.9` 验证:直接 `1F1F` 模式命令由用户目视确认生效;
|
||||||
|
部署修复后 MCP 报告所选通道为 `00001f1f-...`,`set_mode(clock3)` 返回成功。
|
||||||
|
- 自动化测试:212 项通过。
|
||||||
|
|
||||||
|
## [治理] — 2026-08-02
|
||||||
|
|
||||||
|
项目级规则 review 打结(非产品变更):GOVERNANCE §2 目录结构/§5 工具链更新至 0.2.0-beta 现实(jadx→androguard、pyproject 为依赖准绳、交付物扩展为 SDK+CLI+bridge+MCP+examples)、PROGRESS 首日快照误标"最终状态"更正、详细计划 Task 9 标实机完成、DECISIONS 条目 18 标注被取代、README 依赖声明补 qrcode。
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.0-beta] — 2026-07-31
|
||||||
|
|
||||||
新增 **ppclock-mcp**:跑在蓝牙物理所在机器上的单层 MCP 服务器,agent 经 stdio 或
|
新增 **ppclock-mcp**:跑在蓝牙物理所在机器上的单层 MCP 服务器,agent 经 stdio 或
|
||||||
streamable HTTP 直驱墨水屏。
|
streamable HTTP 直驱墨水屏。
|
||||||
@@ -19,6 +36,20 @@ streamable HTTP 直驱墨水屏。
|
|||||||
- 有意不暴露:OTA / 激活 / LUT / WiFi / 轮播(高危或本固件 no-op,走 SDK/CLI)
|
- 有意不暴露:OTA / 激活 / LUT / WiFi / 轮播(高危或本固件 no-op,走 SDK/CLI)
|
||||||
- 文档:`docs/sdk/MCP.md`(工具参考 + agent 挂载 + Windows 部署)
|
- 文档:`docs/sdk/MCP.md`(工具参考 + agent 挂载 + Windows 部署)
|
||||||
|
|
||||||
|
### beta 修正与复测(2026-07-31)
|
||||||
|
- **HTTP 复测通过**(熠管家,LAN 主机 192.168.61.56 → 192.168.61.35:8972):无 token 401 /
|
||||||
|
非法 Host 421 / 真实 LAN Host 200(serverInfo=ppclock)/ `device_status` 全链 ok;
|
||||||
|
详见 spec 末节(`docs/superpowers/specs/2026-07-30-ppclock-mcp-design.md`)
|
||||||
|
- **mcp 依赖地板抬至 `>=1.23`**:`transport_security`(`--allowed-hosts` 依赖的
|
||||||
|
DNS 重绑定防护)自 1.23 引入,且 1.23 起含 CVE-2025-66416 修复;钉 `mcp>=1.23,<2`
|
||||||
|
(2.0 将移除 FastMCP API)
|
||||||
|
- **`--allowed-hosts` 空串防护**:`--allowed-hosts ""` 逗号拆分得空列表,会启用防护并
|
||||||
|
连 localhost 族一并 421(自锁);现按 None 处理(保持 mcp 默认),含回归测试
|
||||||
|
- **qrcode 补入 dependencies**:0.1.0 遗留,`templates.py` qrcode 模板硬依赖
|
||||||
|
(`render_qrcode` 内 import qrcode),pyproject 现钉 `qrcode>=7`
|
||||||
|
- 常驻部署(Windows 服务化 + token 持久化)随熠管家进行,不在本发布内
|
||||||
|
(2026-07-31 当日已完成常驻部署并生产目视闭环,见 docs/sdk/MCP.md §常驻部署、PROGRESS 末节)
|
||||||
|
|
||||||
## [0.1.0] — 2026-07-30
|
## [0.1.0] — 2026-07-30
|
||||||
|
|
||||||
首个 SDK 化发布。4.2寸墨水屏设备(DA14585 BLE,400×300 黑白红三色)的**完全离线**开发包:
|
首个 SDK 化发布。4.2寸墨水屏设备(DA14585 BLE,400×300 黑白红三色)的**完全离线**开发包:
|
||||||
|
|||||||
+9
-1
@@ -15,7 +15,7 @@
|
|||||||
| 2026-07-28 | **RXTX 命令通道 = 331f(非 web 文档的 1f1f)** | 实机 GATT 诊断(bridge services op):1f1f(h=53) 仅 write 无 notify,写入 ATT-ACK 但设备不执行;331f(h=57) notify+write+read,经其实测 EFEF/模式/倒计时/停车牌全部生效 | ppclock 全栈切换(protocol.RXTX_CHAR_UUID=331f、BLETransport/BridgeTransport 写路径);保留 1f1f 为 legacy 常量 |
|
| 2026-07-28 | **RXTX 命令通道 = 331f(非 web 文档的 1f1f)** | 实机 GATT 诊断(bridge services op):1f1f(h=53) 仅 write 无 notify,写入 ATT-ACK 但设备不执行;331f(h=57) notify+write+read,经其实测 EFEF/模式/倒计时/停车牌全部生效 | ppclock 全栈切换(protocol.RXTX_CHAR_UUID=331f、BLETransport/BridgeTransport 写路径);保留 1f1f 为 legacy 常量 |
|
||||||
| 2026-07-28 | **设备 ID 反解参数序实机校正**:snprintf 实参序为 MAC5,MAC4(非固件代理读的 MAC4,MAC5) | 实机 ID '81233F3C267112' 仅按 MAC5 在前才能反解出广播地址 18:BC:5A:5D:BF:28(NVDS 反转序);按原序则掩码矛盾 | protocol.recover_mac_from_device_id 已修正并锁定实机向量测试;firmware-analysis §4.2 对应行以本条为准 |
|
| 2026-07-28 | **设备 ID 反解参数序实机校正**:snprintf 实参序为 MAC5,MAC4(非固件代理读的 MAC4,MAC5) | 实机 ID '81233F3C267112' 仅按 MAC5 在前才能反解出广播地址 18:BC:5A:5D:BF:28(NVDS 反转序);按原序则掩码矛盾 | protocol.recover_mac_from_device_id 已修正并锁定实机向量测试;firmware-analysis §4.2 对应行以本条为准 |
|
||||||
| 2026-07-28 | 设备睡眠行为适配:广播窗口短(约 30-60s),连接态保持清醒 | 实测:空闲即停广播;单连接 batch 可在窗口内跑完全部命令 | 现场测试统一用 `ppclock batch` 单连接;唤醒守候循环(每 15-20s 重试)跨窗口执行 |
|
| 2026-07-28 | 设备睡眠行为适配:广播窗口短(约 30-60s),连接态保持清醒 | 实测:空闲即停广播;单连接 batch 可在窗口内跑完全部命令 | 现场测试统一用 `ppclock batch` 单连接;唤醒守候循环(每 15-20s 重试)跨窗口执行 |
|
||||||
| 2026-07-29 | **SUOTA 状态码 8/16 = 会话信息码而非失败**(仅本固件) | 反汇编 MEM_DEV handler(0x07FCB6F8):type 0x13 命中 bit4 位测试 → 发状态 16 且 SPOTA 状态保持(0x07FCB770-0x07fcb772);type 0x12 → 状态 8 并清会话(0x07FCB780);空镜像探针与正式烧录均先收 16 后流程完好 | bridge ota 对 8/16 记录并继续,块确认只认 2;APK 错误表未覆盖 16,以固件证据为准 |
|
| 2026-07-29 | **SUOTA 状态码 8/16 = 会话信息码而非失败**(仅本固件)〔本条被 2026-07-29 第 5 条(状态码分两类)取代,保留溯源〕 | 反汇编 MEM_DEV handler(0x07FCB6F8):type 0x13 命中 bit4 位测试 → 发状态 16 且 SPOTA 状态保持(0x07FCB770-0x07fcb772);type 0x12 → 状态 8 并清会话(0x07FCB780);空镜像探针与正式烧录均先收 16 后流程完好 | bridge ota 对 8/16 记录并继续,块确认只认 2;APK 错误表未覆盖 16,以固件证据为准 |
|
||||||
| 2026-07-29 | bridge OTA 大请求需显式 StreamReader limit | asyncio 默认 64KiB 行限制,OTA 镜像 hex 请求 ~144KB 永不可达(三轮"静默停滞"根因);空镜像探针 0.6s 全通证明管线无恙 | build 9: start_server(limit=4MiB);教训:大载荷 RPC 协议设计必须考虑传输层帧限 |
|
| 2026-07-29 | bridge OTA 大请求需显式 StreamReader limit | asyncio 默认 64KiB 行限制,OTA 镜像 hex 请求 ~144KB 永不可达(三轮"静默停滞"根因);空镜像探针 0.6s 全通证明管线无恙 | build 9: start_server(limit=4MiB);教训:大载荷 RPC 协议设计必须考虑传输层帧限 |
|
||||||
| 2026-07-29 | **SUOTA 状态码分两类:会话信息码(8/16)与流程错误码**;16=MEM_DEV type 0x13 位测试应答(固件 0x07FCB770),状态保持 | 反汇编 MEM_DEV handler + 三轮烧录对照(16 恒在 MEM_DEV 后 ~1s 到达,块未受影响) | bridge 对 8/16 记录继续、块确认只认 2;APK 表未覆盖 16,固件证据为准 |
|
| 2026-07-29 | **SUOTA 状态码分两类:会话信息码(8/16)与流程错误码**;16=MEM_DEV type 0x13 位测试应答(固件 0x07FCB770),状态保持 | 反汇编 MEM_DEV handler + 三轮烧录对照(16 恒在 MEM_DEV 后 ~1s 到达,块未受影响) | bridge 对 8/16 记录继续、块确认只认 2;APK 表未覆盖 16,固件证据为准 |
|
||||||
| 2026-07-29 | **SUOTA 状态 22/20 根因分层**:22=0x38000 产品头读电气失败(GPIO_MAP 引脚错);20=读成功但非 'pR'(引脚有效、0x38000 疑为空) | GPIO 探针(max_blocks=2 安全模式):A{00,00,06,05}→22、B{40,00,06,05}→22、C{05,06,00,40}→20;C=APK 整数 0x05060040 的 BE 字节,为唯一电气有效候选 | GPIO_MAP 采用 C(BE 序);剩余阻断=0x38000 产品头('pR')缺失/无效,见下条 |
|
| 2026-07-29 | **SUOTA 状态 22/20 根因分层**:22=0x38000 产品头读电气失败(GPIO_MAP 引脚错);20=读成功但非 'pR'(引脚有效、0x38000 疑为空) | GPIO 探针(max_blocks=2 安全模式):A{00,00,06,05}→22、B{40,00,06,05}→22、C{05,06,00,40}→20;C=APK 整数 0x05060040 的 BE 字节,为唯一电气有效候选 | GPIO_MAP 采用 C(BE 序);剩余阻断=0x38000 产品头('pR')缺失/无效,见下条 |
|
||||||
@@ -24,3 +24,11 @@
|
|||||||
| 2026-07-29 | **全量烧录实证安全性论证**:产品头校验失败时所有缓冲处理在写 flash 前中止(state[0x70] 恒 0),END 仅写 0xAA 于 [state[0x60]+2]=0x0002 而该字节原本就是 0xAA(无操作),复位后裸启原固件 | 反汇编 END 路径(0x07FCB7C6-0x07FCB7E4)与缓冲写路径(0x07FCB514 依赖中止的头部处理产物 state[0x74]) | 全量跑 OTA 的最坏结果=安全空转+复位;若中途校验通过则自然转正为真实烧录。用户授权下可执行 |
|
| 2026-07-29 | **全量烧录实证安全性论证**:产品头校验失败时所有缓冲处理在写 flash 前中止(state[0x70] 恒 0),END 仅写 0xAA 于 [state[0x60]+2]=0x0002 而该字节原本就是 0xAA(无操作),复位后裸启原固件 | 反汇编 END 路径(0x07FCB7C6-0x07FCB7E4)与缓冲写路径(0x07FCB514 依赖中止的头部处理产物 state[0x74]) | 全量跑 OTA 的最坏结果=安全空转+复位;若中途校验通过则自然转正为真实烧录。用户授权下可执行 |
|
||||||
| 2026-07-29 | **用户明确授权全量烧录**(301 块+END+复位,gpio_map=BE 序 C),要求全程记录与 review | 用户原话:"授权 注意做好记录和review"(此前已授权"做一下审慎测试后刷机") | 执行范围:fw_tst.img(DIS 版本串 3 字符差异);留证:状态流日志 docs/suota-flash-log.jsonl;事后验证:scan/connect/DIS 读 |
|
| 2026-07-29 | **用户明确授权全量烧录**(301 块+END+复位,gpio_map=BE 序 C),要求全程记录与 review | 用户原话:"授权 注意做好记录和review"(此前已授权"做一下审慎测试后刷机") | 执行范围:fw_tst.img(DIS 版本串 3 字符差异);留证:状态流日志 docs/suota-flash-log.jsonl;事后验证:scan/connect/DIS 读 |
|
||||||
| 2026-07-29 | **G4 固件可控路径选型:B(免线刷软件利用)**,放弃 UART 线刷恢复工厂布局(路径 A 文档保留备查) | 用户原话:"肯定是B";B 路径已具备:图像 flash 持久化写(0x2E000)、EF 激活 flash 写(0x3Fxxx)+ 离线 keygen、全部 BLE 功能可控、SUOTA 工具链就绪(工厂布局设备可直接复用) | 任务说明目标3 以 B 路径达标;A 路径(线刷 .bin + UART)作为可选增强留存 docs/firmware-layout.md |
|
| 2026-07-29 | **G4 固件可控路径选型:B(免线刷软件利用)**,放弃 UART 线刷恢复工厂布局(路径 A 文档保留备查) | 用户原话:"肯定是B";B 路径已具备:图像 flash 持久化写(0x2E000)、EF 激活 flash 写(0x3Fxxx)+ 离线 keygen、全部 BLE 功能可控、SUOTA 工具链就绪(工厂布局设备可直接复用) | 任务说明目标3 以 B 路径达标;A 路径(线刷 .bin + UART)作为可选增强留存 docs/firmware-layout.md |
|
||||||
|
| 2026-07-30 | MCP 连接管理选型**方案 C:按需连接 + 空闲保持 + 串行锁 + 掉线重连一次** | 三方拉扯:设备长睡眠(广播窗口仅 30-60s)vs agent 连续操作体验(每操作重连会撞睡眠窗口)vs 电池(常连使设备保持清醒耗电) | 首次操作守候连接(窗口覆盖 30-60s 广播),操作后保持连接、空闲 `--idle-timeout`(默认 300s)自动断开让设备睡眠;一把 asyncio.Lock 串行全部操作;操作中掉线重连重试一次再向上抛;退化配置:`--idle-timeout 0`(每操作即断,最省电)或大值(近常连,体验最佳);详见 docs/superpowers/specs/2026-07-30-ppclock-mcp-design.md |
|
||||||
|
| 2026-07-31 | 引入 mcp 依赖(ppclock-mcp extra)并钉 `>=1.23,<2` | 0.2.0-beta 新增 MCP 服务器层;GOVERNANCE 要求依赖引入留痕;mcp 2.0 移除 FastMCP/memory API | `<2` 防 2.0 API 断裂;`>=1.23` 因 `transport_security`(DNS 重绑定防护,`--allowed-hosts` 依赖)自 1.23 引入,且 1.23 起含 CVE-2025-66416 修复;本地与 Windows 实测均为 1.29.0 |
|
||||||
|
| 2026-07-31 | 0.2.0-beta 终审裁定放行项终裁:TRANSPORT_ERRORS 含 OSError 的理论变宽、token 非恒定时间比较、Image.open 惰性解码失败归入 INTERNAL | 终审提出的三处理论风险,需终裁留痕 | 均判低风险放行:OSError 变宽仅使极端平台错误归入 BLE_ERROR(语义可接受);token 比较在可信 LAN + Bearer 场景时序攻击不可行;Image.open 惰性解码异常有 INTERNAL 兜底且 message 带类型详情 |
|
||||||
|
| 2026-07-31 | `--allowed-hosts ""` 空串按 None 处理 | 空串逗号拆分得空列表,`TransportSecuritySettings(allowed_hosts=[])` 启用防护后连 localhost 族也 421(自锁) | main() 解析层 `_parse_allowed_hosts` 以 `or None` 归一,保持 mcp 默认 localhost 族;含回归测试 |
|
||||||
|
| 2026-07-31 | **双 TransportError 历史包袱在 MCP 层适配,SDK 零改动** | `transports.base.TransportError` 与 `transports.local.TransportError` 同名但互不继承(历史包袱);真实 bleak 写/读路径还裸抛 `BleakError` 不包装 | 改 SDK 会动公开语义、风险大于收益;MCP 层 `device_manager.py:19-26` 以 `TRANSPORT_ERRORS` 全家捕获兜底(base/local 两类 + BleakError + OSError + asyncio.TimeoutError),掉线判定不漏形态 |
|
||||||
|
| 2026-08-01 | 建立 Gitea 远端(chenwei/qianbian)并增补 Git 协作治理(GOVERNANCE §6) | 用户要求版本控制上云 + 后续治理迭代;此前仓库仅本地 | main 主干直提、分支短命、身份诚实不改写、推送前三项检查(净树/全绿/无 secret)、大文件仅原始输入随库。**远端落地**:origin=git@git.b.aeroprop.xyz:chenwei/qianbian.git(实例 git.b.aeroprop.xyz,展示域 git.banytech.com);认证=本机 ~/.ssh/id_ed25519(Gitea key 名 chenwei@bany.tech@openclaw-server);2026-08-02 全历史已推送(ls-remote 校验一致) |
|
||||||
|
| 2026-08-02 | 项目级规则 review 打结:8 项不一致修复(PROGRESS 首日快照误标"最终状态"、GOVERNANCE §2 目录/§5 工具过期、PLAN P0 jadx、详细计划 Task 9 终态、DECISIONS 条目 18/20 重复标注、README 依赖缺 qrcode) | 用户要求规则 review;项目已从 0.1.0 演进至 0.2.0-beta(SDK/MCP/bridge/实机验证),多文档口径落后于现实 | 规则文件必须与交付现实一致;不改写历史(快照保留标注、重复条目保留溯源),仅修正指向与口径;GOVERNANCE 变更按 §4 留痕本条 |
|
||||||
|
| 2026-08-03 | **RXTX 通道按固件家族选择**:`HM42_AIO_*` 使用写入型 `1F1F`;已验证 `PP_da14585_4.2` 继续使用 `331F`;未知固件优先 `331F` 并按 GATT 存在性回退 | HP 侧同型号异供应商设备报告 `HM42_AIO_V1.0.9`;原 SDK 写 `331F` 均返回成功但屏幕不变。GATT 枚举显示其 `1F10` 控制服务只有 write-only `1F1F`;直写 Web-BLE 序列 `e10202` + `e2` 后用户目视确认切至“日历+表” | ATT 写成功不等于设备执行。连接时读取 DIS `2A26` 并选型;仅在特征支持 notify/indicate 时订阅,避免 write-only HM42 通道误启通知。状态接口公开固件串和实际 RXTX UUID,便于运维审计 |
|
||||||
+28
-7
@@ -18,25 +18,35 @@ qianbian/
|
|||||||
├── PLAN.md # 总体规划与阶段验收标准(指向 docs/superpowers/plans/)
|
├── PLAN.md # 总体规划与阶段验收标准(指向 docs/superpowers/plans/)
|
||||||
├── PROGRESS.md # 实时进度日志:每次工作会话结束必须更新
|
├── PROGRESS.md # 实时进度日志:每次工作会话结束必须更新
|
||||||
├── DECISIONS.md # 决策日志:每条决策含日期、背景、结论、理由
|
├── DECISIONS.md # 决策日志:每条决策含日期、背景、结论、理由
|
||||||
|
├── CHANGELOG.md # 产品变更记录(release 口径)
|
||||||
|
├── llms.txt # AI 消费向项目卡(随发布同步)
|
||||||
├── app/ # 原始输入物(APK、固件,只读,不得修改)
|
├── app/ # 原始输入物(APK、固件,只读,不得修改)
|
||||||
├── docs/ # 所有分析文档
|
├── docs/ # 所有分析文档
|
||||||
│ ├── architecture.md # 软硬件架构总述
|
│ ├── architecture.md # 软硬件架构总述
|
||||||
│ ├── protocol.md # BLE 协议规范(最终单一事实来源)
|
│ ├── protocol.md # BLE 协议规范(最终单一事实来源)
|
||||||
│ ├── firmware-analysis.md # 固件逆向笔记
|
│ ├── firmware-analysis.md # 固件逆向笔记
|
||||||
│ └── superpowers/plans/ # 详细实现计划
|
│ ├── firmware-layout.md # 闪存布局(裸/工厂)与 SUOTA 门禁
|
||||||
|
│ ├── field-test-plan.md / field-test-result.md # 全链路测试计划与实测
|
||||||
|
│ ├── sdk/ # API.md(签名级)/ MCP.md(MCP 服务器)
|
||||||
|
│ └── superpowers/{plans,specs}/ # 详细实施计划与设计规格
|
||||||
├── analysis/ # 逆向中间产物(反编译输出、脚本、抓包等)
|
├── analysis/ # 逆向中间产物(反编译输出、脚本、抓包等)
|
||||||
│ ├── apk/ # APK 反编译产物(不入库的大文件除外)
|
│ ├── apk/ # APK 反编译产物(不入库的大文件除外)
|
||||||
│ ├── firmware/ # 固件反汇编/提取产物
|
│ ├── firmware/ # 固件反汇编/提取产物
|
||||||
│ └── web/ # Web 上位机抓取产物
|
│ └── web/ # Web 上位机抓取产物
|
||||||
├── src/ppclock/ # 本地 CLI 上位机(交付物)
|
├── src/ppclock/ # 交付物:SDK+CLI(PPClient/ppclock)
|
||||||
├── tools/ # 固件处理等辅助工具(交付物)
|
│ ├── transports/ # base/local(bleak)/bridge(RPC) 传输层
|
||||||
└── tests/ # CLI 与工具的自动化测试
|
│ ├── firmware/ # 镜像解析重打包 + SUOTA
|
||||||
|
│ ├── bridge_server.py # ppclock-bridge 服务端
|
||||||
|
│ └── device_manager.py / mcp_server.py / mcp_tools.py # ppclock-mcp
|
||||||
|
├── tools/ # 辅助工具(fw 垫片/field_test/gpio_probe 等)
|
||||||
|
├── examples/ # 示例与测试夹具(jsonl 批量、测试图)
|
||||||
|
└── tests/ # 自动化测试(当前 208 项)
|
||||||
```
|
```
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
- `app/` 内原始文件**只读**;所有修改副本放 `analysis/`。
|
- `app/` 内原始文件**只读**;所有修改副本放 `analysis/`。
|
||||||
- 每个分析结论必须写明证据(文件偏移 / 代码位置 / 抓包数据),禁止无证据断言。
|
- 每个分析结论必须写明证据(文件偏移 / 代码位置 / 抓包数据),禁止无证据断言。
|
||||||
- 交付物只有两类:`src/ppclock`(CLI)与 `tools/`(固件工具)+ `docs/`(文档)。
|
- 交付物:`src/ppclock`(SDK+CLI+bridge+MCP)、`tools/`、`examples/`、`docs/`。
|
||||||
|
|
||||||
## 3. 验证门禁(每个阶段必须满足才算完成)
|
## 3. 验证门禁(每个阶段必须满足才算完成)
|
||||||
|
|
||||||
@@ -60,6 +70,17 @@ qianbian/
|
|||||||
|
|
||||||
## 5. 工具与环境
|
## 5. 工具与环境
|
||||||
|
|
||||||
- 分析环境:Python 3.12(venv 于 `.venv/`)、jadx、capstone、bleak。
|
- 分析环境:Python 3.12(venv 于 `.venv/`)、androguard(APK)、capstone(固件反汇编)、bleak(BLE)。
|
||||||
- 依赖锁定:`requirements.txt` 固定版本;新增依赖需记录于 DECISIONS.md。
|
(注:jadx 因下载策略未采用,APK 分析全程 androguard。)
|
||||||
|
- 依赖声明以 `pyproject.toml` 为准(runtime:bleak/pillow/qrcode;extras:dev/mcp);
|
||||||
|
`requirements.txt` 为其冻结镜像。新增/升级依赖需记录于 DECISIONS.md。
|
||||||
- 外部工具一律装在本机,不修改系统关键组件。
|
- 外部工具一律装在本机,不修改系统关键组件。
|
||||||
|
|
||||||
|
## 6. Git 与远端协作(2026-08 增补,DECISIONS 2026-08-01)
|
||||||
|
|
||||||
|
1. **远端**:Gitea 实例,chenwei 账户下同名仓库 `qianbian`(remote 名 `origin`)。URL 见 DECISIONS.md 2026-08-01 条目。
|
||||||
|
2. **分支策略**:`main` 为唯一主干,直接提交;实验性大改动用 `<topic>` 短分支,回合并即删。不使用 force push 改写 main 历史。
|
||||||
|
3. **提交身份**:agent 工作用 `agent <agent@local>`,人工工作用本人身份——不混用、不改写历史身份,保证溯源诚实。
|
||||||
|
4. **推送前检查**:工作树干净、`pytest` 全绿、无新增 secret(token/私钥/真实密码)。测试用口令(如 `pw123456`)允许但必须在测试/文档语境。
|
||||||
|
5. **大文件**:`app/` 原始输入(APK 9.3MB、固件 72KB)随库分发(项目自包含要求);其余 >1MB 产物须为文档/证据类文本,二进制中间产物一律 gitignore + 再生方法入库(analysis/README.md)。
|
||||||
|
6. **同步纪律**:每个工作会话开始先 `git pull --ff-only`;结束前提交并 `git push`。冲突时以远端为准 rebase 本地未提交改动,禁止 `--force`。
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
| 阶段 | 目标 | 对应任务说明 | 门禁 |
|
| 阶段 | 目标 | 对应任务说明 | 门禁 |
|
||||||
|------|------|--------------|------|
|
|------|------|--------------|------|
|
||||||
| P0 工具链 | venv + jadx/androguard/capstone/bleak | — | 自检全 OK |
|
| P0 工具链 | venv + androguard/capstone/bleak | — | 自检全 OK |
|
||||||
| P1 三线逆向 | Web JS → APK → 固件互证,产出 architecture.md + protocol.md | 目标1 | **G1** 三线互证一致 |
|
| P1 三线逆向 | Web JS → APK → 固件互证,产出 architecture.md + protocol.md | 目标1 | **G1** 三线互证一致 |
|
||||||
| P2 CLI 上位机 | 协议层/图像管线/命令层/CLI,功能对齐 Web 上位机 | 目标2 | **G2** 测试全绿+JSON schema;**G3** 功能全覆盖 |
|
| P2 CLI 上位机 | 协议层/图像管线/命令层/CLI,功能对齐 Web 上位机 | 目标2 | **G2** 测试全绿+JSON schema;**G3** 功能全覆盖 |
|
||||||
| P3 增强+固件可控 | 本地模板渲染/批量/调度;固件解析重打包+SUOTA | 目标2增强、目标3 | **G4** 镜像可改可重生成;**G5** 断网验收 |
|
| P3 增强+固件可控 | 本地模板渲染/批量/调度;固件解析重打包+SUOTA | 目标2增强、目标3 | **G4** 镜像可改可重生成;**G5** 断网验收 |
|
||||||
|
|||||||
+40
-3
@@ -1,6 +1,10 @@
|
|||||||
# 进度日志 (PROGRESS)
|
# 进度日志 (PROGRESS)
|
||||||
|
|
||||||
## 2026-07-24(最终状态)
|
> **最终状态以 2026-07-29 收官节与后续各节为准**。本节(2026-07-24)为首日快照,
|
||||||
|
> 其中 G3/G4 的"协议级/工具级"标注已被 2026-07-28/29 实机验证超越:G3 ✅实机(除 K1 停车牌待查)、
|
||||||
|
> G4 ✅路径B达标、测试 132→208、交付物扩展为 SDK+CLI+bridge+MCP。
|
||||||
|
|
||||||
|
## 2026-07-24(首日快照)
|
||||||
|
|
||||||
### 已完成(对照门禁)
|
### 已完成(对照门禁)
|
||||||
|
|
||||||
@@ -220,8 +224,14 @@
|
|||||||
- 三新模块:`device_manager.py`(方案 C:按需连接+空闲超时保持+串行锁+掉线重连重试一次)/ `mcp_tools.py`(13 高层工具+统一 ok/error 契约)/ `mcp_server.py`(stdio+streamable HTTP 双模,PPCLOCK_MCP_TOKEN 鉴权,非回环强制 token,--allowed-hosts Host 白名单)
|
- 三新模块:`device_manager.py`(方案 C:按需连接+空闲超时保持+串行锁+掉线重连重试一次)/ `mcp_tools.py`(13 高层工具+统一 ok/error 契约)/ `mcp_server.py`(stdio+streamable HTTP 双模,PPCLOCK_MCP_TOKEN 鉴权,非回环强制 token,--allowed-hosts Host 白名单)
|
||||||
- 规格 spec + 实施计划入库(docs/superpowers/specs|plans);207 测试全绿(新增 42)
|
- 规格 spec + 实施计划入库(docs/superpowers/specs|plans);207 测试全绿(新增 42)
|
||||||
- **终审关键修复**:全部 fake 曾只说英文 TransportError——真实 bleak 抛中文 `transports.local.TransportError`(与 base 同名互不继承)与未包装 BleakError;捕获面/错误码映射已按真实异常语义重写(类型优先、中英文消息次之)
|
- **终审关键修复**:全部 fake 曾只说英文 TransportError——真实 bleak 抛中文 `transports.local.TransportError`(与 base 同名互不继承)与未包装 BleakError;捕获面/错误码映射已按真实异常语义重写(类型优先、中英文消息次之)
|
||||||
- **Windows 实测**(熠管家,banyWinServer 192.168.61.35,C:\ppclock):stdio 全链路通过(scan→connect→set_time→upload_image 15000+15000→set_mode image0;远程无法目视上屏);HTTP LAN Host 421(mcp DNS 重绑定防护)已修为 --allowed-hosts,复测待回报
|
- **Windows 实测**(熠管家,banyWinServer 192.168.61.35,C:\ppclock):stdio 全链路通过(scan→connect→set_time→upload_image 15000+15000→set_mode image0;远程无法目视上屏);HTTP LAN Host 421(mcp DNS 重绑定防护)已修为 --allowed-hosts,复测已命中(401/421/200/device_status ok,2026-07-31,详见 spec 末节)
|
||||||
- 环境事实:mcp 钉 >=1.10,<2(2.0 移除 FastMCP/memory API;实测 1.29.0)
|
- 环境事实:mcp 钉 >=1.23,<2(2.0 移除 FastMCP/memory API;1.23 起有 transport_security + CVE-2025-66416 修复;实测 1.29.0)
|
||||||
|
|
||||||
|
### 2026-07-31:0.2.0-beta release + 常驻部署(生产性测试开始)
|
||||||
|
|
||||||
|
- **release**:节点审计 8 项必修落地(版本 7 处统一 0.2.0b1、mcp 地板 >=1.23、--allowed-hosts 空串自锁修复+回归、文档数 208、DECISIONS 留痕);tag `v0.2.0-beta` @ 5130bb7(旧 v0.2.0 删除);208 测试全绿
|
||||||
|
- **常驻部署**(熠管家,banyWinServer):SYSTEM 计划任务 `BanyTech-PPCLOCK-MCP`(AtStartup + 999 次/1 分钟恢复 + runner 5 秒子进程重启);token CSPRNG 生成 ACL 仅 SYSTEM/Administrators;日志 `C:\ProgramData\OpenClaw\ppclock-mcp\logs\`;验证:LAN 200/device_status ok/杀进程 5 秒自动拉起复 200;8971 与防火墙不变
|
||||||
|
- **运维记录**:`/home/cwmine/vps/host-win/maintain_logs/2026-07-31_ppclock-mcp-production-test-service.md`;运行问题经 Bridge task `ppclock-mcp-ops` 回报本 session 统一迭代
|
||||||
- 文档:docs/sdk/MCP.md(工具参考+部署)、README/llms.txt/API.md/CHANGELOG 同步;工具清单 tools/mcp_windows_deploy.md
|
- 文档:docs/sdk/MCP.md(工具参考+部署)、README/llms.txt/API.md/CHANGELOG 同步;工具清单 tools/mcp_windows_deploy.md
|
||||||
|
|
||||||
### 会话时间线
|
### 会话时间线
|
||||||
@@ -232,3 +242,30 @@
|
|||||||
- P1:Web JS 协议提取(子代理)→ APK 反编译互证(子代理)→ 固件逆向(子代理)
|
- P1:Web JS 协议提取(子代理)→ APK 反编译互证(子代理)→ 固件逆向(子代理)
|
||||||
- P2:protocol/image/commands/transport/cli/templates/textbitmap 全 TDD 实现
|
- P2:protocol/image/commands/transport/cli/templates/textbitmap 全 TDD 实现
|
||||||
- P3:batch/本地模板/ota/keygen 增强;fw 工具链;文档终稿
|
- P3:batch/本地模板/ota/keygen 增强;fw 工具链;文档终稿
|
||||||
|
|
||||||
|
**生产接口实测**(2026-07-31,熠管家代跑,原始 JSON 入库):
|
||||||
|
- A 疑案闭环:「connected=false」为 OpenClaw 侧解析误读(读了顶层 connected/mac 而非 data.*,缺字段强转 false),非服务问题;原始 payload data.connected=true / data.mac=18:BC:5A:5D:BF:28 / connect_count=2
|
||||||
|
- B 授权六步全过(每步 HTTP 200/isError=false/ok=true):set_time tz=8 → upload_image slot0(bytes 15000+15000)→ set_mode image0 → countdown 2026-12-31 目标 → countdown_off(自回滚)→ device_status connected=true;屏显目视归用户
|
||||||
|
- 观察项:supervisor.log 09:16-09:18 曾现 exit -1 每 6 秒重启循环约 2 分钟(疑端口占用竞态,runner 加日志重定向后 09:20:50 起稳定至今)——运维关注,非代码缺陷结论待续观
|
||||||
|
- **目视闭环**(2026-07-31):首轮"屏幕未变"系夹具顺序缺陷(countdown(mode=clock) 覆盖 image0 可见态);单步 set_mode(image0) 复测,**用户目视确认 MCP-PROD-TEST 测试图上屏**——生产链路 agent→MCP→BLE→屏 全链目视闭环
|
||||||
|
|
||||||
|
### 2026-08-02:Gitea 远端上线(治理闭环)
|
||||||
|
|
||||||
|
- 熠管家在 Gitea(git.b.aeroprop.xyz)chenwei 账户下创建空仓库 qianbian(API 复核 empty=true)
|
||||||
|
- 本机认证:~/.ssh/id_ed25519 已在 Gitea 注册(key 名 chenwei@bany.tech@openclaw-server)
|
||||||
|
- origin 已添加并全历史推送(ls-remote HEAD 与本地一致:3ce4fc0)
|
||||||
|
- 治理落地:GOVERNANCE §6(remote/分支/身份/推送检查/大文件/同步纪律)+ DECISIONS 2026-08-01(含 URL 与认证方式)
|
||||||
|
- 推送前审计:secret 扫描干净(仅协议格式串与测试口令)、提交身份 agent/chenwei 两类诚实并存不改写
|
||||||
|
|
||||||
|
### 2026-08-03:HM42 异固件控制通道适配
|
||||||
|
|
||||||
|
- 新设备 DIS 实读:`DA14585 / HM42_AIO_V1.0.9 / Dialog Semi`,区别于参考设备
|
||||||
|
`PP_da14585_4.2 / 1.0.0.0-LE`。
|
||||||
|
- 原 SDK 的 `331F` 写操作虽有 ATT ACK,但屏幕无变化;GATT 枚举确认 HM42 的
|
||||||
|
`1F10` 控制服务仅暴露 write-only `1F1F`。
|
||||||
|
- 复刻 Web-BLE 模式序列直写 `1F1F`,用户目视确认切换为“日历+表”,完成协议证据闭环。
|
||||||
|
- SDK 已实现按 DIS 固件串选通道、GATT 存在性回退及条件通知订阅;MCP 状态公开
|
||||||
|
`firmware_revision`/`rxtx_uuid`。自动化测试 212 项全绿。
|
||||||
|
- HP 运行源已备份后部署并校验哈希,远端编译通过;MCP 实读
|
||||||
|
`HM42_AIO_V1.0.9 + 1F1F`,随后 `set_mode(clock3)` 返回 HTTP 200/ok。
|
||||||
|
- 最终屏显 `clock3` 的现场目视确认仍由用户回报;在目视前不把 MCP 返回值单独算作上屏成功。
|
||||||
@@ -12,7 +12,7 @@ Python SDK(`PPClient`)+ CLI(`ppclock`)+ BLE 桥(`ppclock-bridge`),
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -e ".[dev]" # 依赖:bleak、pillow(dev: pytest)
|
.venv/bin/pip install -e ".[dev]" # 依赖:bleak、pillow、qrcode(dev: pytest、pytest-asyncio)
|
||||||
```
|
```
|
||||||
|
|
||||||
运行要求:蓝牙适配器(本机 bleak)**或** 他机蓝牙经 `ppclock-bridge` 桥接。**不需要互联网**。
|
运行要求:蓝牙适配器(本机 bleak)**或** 他机蓝牙经 `ppclock-bridge` 桥接。**不需要互联网**。
|
||||||
@@ -115,7 +115,7 @@ ppclock --json ota firmware.img --yes # SUOTA
|
|||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python -m pytest tests/ -q # 195 测试
|
.venv/bin/python -m pytest tests/ -q # 208 测试
|
||||||
```
|
```
|
||||||
|
|
||||||
治理规则见 `GOVERNANCE.md`;变更记录 `CHANGELOG.md`;决策日志 `DECISIONS.md`。
|
治理规则见 `GOVERNANCE.md`;变更记录 `CHANGELOG.md`;决策日志 `DECISIONS.md`。
|
||||||
|
|||||||
+11
-2
@@ -131,12 +131,21 @@ ASCII UTF-8 直发:`WIFI_SSID:{ssid};WIFI_PASS:{pass};CITY:{城市代码}`(
|
|||||||
|
|
||||||
## 6. 标准会话流程
|
## 6. 标准会话流程
|
||||||
|
|
||||||
1. 扫描(namePrefix `NRF-`)→ 连接 → EPD service → RXTX service → 订阅两通道 notify。
|
1. 扫描(namePrefix `NRF-`)→ 连接 → 读取 DIS 固件版本 → 按固件选择 RXTX 特征。`PP_da14585_4.2` 使用 `331F`(notify/write/read);`HM42_AIO_*` 使用 `1F1F`(write-only,不订阅 notify)。首选特征不存在时才回退到另一特征。
|
||||||
2. (可选)`EFEF` 取设备 ID。
|
2. (可选)`EFEF` 取设备 ID。
|
||||||
3. 对时:`dd…` + `e2`。
|
3. 对时:`dd…` + `e2`。
|
||||||
4. 操作:模式切换(+`e2`)/ 传图(`EA0{slot}` → 数据块×N → `01`/`AA`)/ 倒计时 / WiFi。
|
4. 操作:模式切换(+`e2`)/ 传图 / 倒计时 / WiFi。
|
||||||
5. 断线自动重连 ≤5 次。
|
5. 断线自动重连 ≤5 次。
|
||||||
|
|
||||||
|
### 6.1 图像协议的固件差异
|
||||||
|
|
||||||
|
| 固件家族 | RXTX | 400×300 单色平面 | EPD 图像会话 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PP_da14585_4.2` / 未知 | `331F` | 黑白平面 | 旧序列:数据块 → `01`(小图 `AA`) |
|
||||||
|
| `HM42_AIO_*` | `1F1F` | 黑白 15,000B + 全零红色 15,000B | `8001` → `0000` → `020000` → 双平面数据 → `0101` → RXTX `e2` |
|
||||||
|
|
||||||
|
HM42 的双平面和会话帧均为必需条件:缺红色平面会出现红屏噪点,缺会话帧会出现黑白噪点。400×300 双平面在 Windows BLE 实机约需 8 分钟;调用方不得依赖跨网络的单个长 HTTP 连接,应在 BLE 主机本地执行并持久化结果后再轮询。
|
||||||
|
|
||||||
## 7. 本地化(去云)清单 —— 已全部解决
|
## 7. 本地化(去云)清单 —— 已全部解决
|
||||||
|
|
||||||
| 云依赖 | 本地化方案 | 状态 |
|
| 云依赖 | 本地化方案 | 状态 |
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# ppclock SDK · API 参考(v0.2.0)
|
# ppclock SDK · API 参考(v0.2.0-beta)
|
||||||
|
|
||||||
> 面向二次开发的签名级参考。协议细节见 `docs/protocol.md`,固件内部见 `docs/firmware-analysis.md`。MCP 服务器(ppclock-mcp)工具参考见 `docs/sdk/MCP.md`。
|
> 面向二次开发的签名级参考。协议细节见 `docs/protocol.md`,固件内部见 `docs/firmware-analysis.md`。MCP 服务器(ppclock-mcp)工具参考见 `docs/sdk/MCP.md`。
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ from ppclock import PPClient
|
|||||||
|
|
||||||
| 方法 | 签名 | 说明 |
|
| 方法 | 签名 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `upload_image` | `async (source, slot=None, algo="atkinson", mono=False, size=(400,300), **adjust) -> dict` | source=路径或 PIL.Image;algo∈`none/floydsteinberg/atkinson/bayer/stucki/jarvis`;adjust∈`threshold/diffusion/brightness/contrast/saturation/rotate`;返回 `{"bytes_bw","bytes_red","small","slot"}`。小图(3500B/9000B)自动走 cod=04 |
|
| `upload_image` | `async (source, slot=None, algo="atkinson", mono=False, size=(400,300), **adjust) -> dict` | source=路径或 PIL.Image;algo∈`none/floydsteinberg/atkinson/bayer/stucki/jarvis`;adjust∈`threshold/diffusion/brightness/contrast/saturation/rotate`;返回 `{"bytes_bw","bytes_red","small","slot"}`。小图(3500B/9000B)自动走 cod=04;`HM42_AIO_*` 会自动补齐双平面并使用厂商图像会话 |
|
||||||
| `render_template` | `async (name, payload=None, slot=None, algo="atkinson") -> dict` | 6 模板本地渲染上传:`schedule/businesscard/memo/course/qrcode/custom`;payload 见 `src/ppclock/templates.py` docstring |
|
| `render_template` | `async (name, payload=None, slot=None, algo="atkinson") -> dict` | 6 模板本地渲染上传:`schedule/businesscard/memo/course/qrcode/custom`;payload 见 `src/ppclock/templates.py` docstring |
|
||||||
|
|
||||||
### 倒计时与文字
|
### 倒计时与文字
|
||||||
|
|||||||
+17
-2
@@ -1,4 +1,4 @@
|
|||||||
# ppclock-mcp · MCP 服务器参考(v0.2.0)
|
# ppclock-mcp · MCP 服务器参考(v0.2.0-beta)
|
||||||
|
|
||||||
> 面向 agent 挂载与部署的工具级参考。SDK 签名见 `docs/sdk/API.md`,协议细节见 `docs/protocol.md`。
|
> 面向 agent 挂载与部署的工具级参考。SDK 签名见 `docs/sdk/API.md`,协议细节见 `docs/protocol.md`。
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ streamable HTTP 直接驱动 4.2" 墨水屏设备。
|
|||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[mcp]" # 追加依赖:mcp>=1.10,<2
|
pip install -e ".[mcp]" # 追加依赖:mcp>=1.23,<2
|
||||||
```
|
```
|
||||||
|
|
||||||
要求:Python ≥ 3.10、本机蓝牙适配器(bleak)。服务器与被控设备在同一台蓝牙主机上;
|
要求:Python ≥ 3.10、本机蓝牙适配器(bleak)。服务器与被控设备在同一台蓝牙主机上;
|
||||||
@@ -127,6 +127,21 @@ setx PPCLOCK_MCP_TOKEN "<token>" # 或按服务方式注入环境变量
|
|||||||
- 不动既有 **8971** bridge 与其他服务:8972 为独立端口、独立进程。
|
- 不动既有 **8971** bridge 与其他服务:8972 为独立端口、独立进程。
|
||||||
- `--mac` 建议显式指定设备地址,避免自动扫描误绑其他 `NRF-` 设备。
|
- `--mac` 建议显式指定设备地址,避免自动扫描误绑其他 `NRF-` 设备。
|
||||||
|
|
||||||
|
### 常驻部署(2026-07-31 生产性测试实例)
|
||||||
|
|
||||||
|
参考实现(banyWinServer 192.168.61.35,v0.2.0-beta,熠管家运维):
|
||||||
|
|
||||||
|
- **常驻方式**:SYSTEM 计划任务 `BanyTech-PPCLOCK-MCP`(AtStartup、最高权限、
|
||||||
|
执行时间不限、Task Scheduler 999 次/1 分钟恢复);runner 脚本另有 5 秒子进程重启。
|
||||||
|
- **token**:Windows CSPRNG 生成,存 `C:\ProgramData\OpenClaw\ppclock-mcp\token.txt`,
|
||||||
|
ACL 仅 SYSTEM/Administrators 可读;不入仓、不写日志。
|
||||||
|
- **日志**:同目录 `logs\`——`supervisor.log` 记版本/启动参数/退出,
|
||||||
|
stdout/stderr 按次落文件。
|
||||||
|
- **验证基线**:真实 LAN Host initialize 200、`device_status` ok;
|
||||||
|
杀 8972 进程后 5 秒自动拉起、initialize 复 200。
|
||||||
|
- agent 挂载:`http://192.168.61.35:8972/mcp` + `Authorization: Bearer <token>`。
|
||||||
|
- 运维记录原件:`/home/cwmine/vps/host-win/maintain_logs/2026-07-31_ppclock-mcp-production-test-service.md`。
|
||||||
|
|
||||||
## 不暴露的能力
|
## 不暴露的能力
|
||||||
|
|
||||||
以下 SDK 能力**有意不**注册为 MCP 工具:
|
以下 SDK 能力**有意不**注册为 MCP 工具:
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ apt-get install -y jadx 2>/dev/null || (cd /opt && curl -sL -o jadx.zip https://
|
|||||||
- [x] Step 2: `--json` 输出 schema 固定(`{"ok":bool,"cmd":str,"data":...,"error":...}`),错误码文档化
|
- [x] Step 2: `--json` 输出 schema 固定(`{"ok":bool,"cmd":str,"data":...,"error":...}`),错误码文档化
|
||||||
- [x] Step 3: mock transport 的 CLI 集成测试全绿;提交;**G2 门禁检查**
|
- [x] Step 3: mock transport 的 CLI 集成测试全绿;提交;**G2 门禁检查**
|
||||||
|
|
||||||
### Task 9: 真机功能对齐验证 —— ⚠ 降级(无设备在 BLE 范围内,2026-07-24)
|
### Task 9: 真机功能对齐验证 —— ✅ 完成(2026-07-28/29 实机)
|
||||||
|
|
||||||
- [ ] Step 1: 对照 protocol.md 功能清单逐项在真机执行 CLI(每步记录结果与抓包)——**待设备到场执行**
|
- [x] Step 1: 对照 protocol.md 功能清单逐项在真机执行 CLI(每步记录结果与抓包)——**2026-07-28/29 经 bridge 实机完成**(六步全过+扩展 12 步+六抖动+六模板,记录 docs/field-test-result.md;K1 停车牌待查为唯一遗留)
|
||||||
- [x] Step 2(降级): 以 FakeTransport 逐帧断言 + 三线证据互证替代(tests/test_cli.py、test_commands.py);G3 以协议级达标记录于 PROGRESS.md
|
- [x] Step 2(降级): 以 FakeTransport 逐帧断言 + 三线证据互证替代(tests/test_cli.py、test_commands.py);G3 以协议级达标记录于 PROGRESS.md
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ppclock — 4.2寸墨水屏设备(DA14585 BLE)离线 SDK
|
# ppclock — 4.2寸墨水屏设备(DA14585 BLE)离线 SDK
|
||||||
|
|
||||||
> AI 消费向项目卡(llms.txt v0.2.0)。人用文档见 README.md;签名级 API 见 docs/sdk/API.md;MCP 服务器见 docs/sdk/MCP.md。
|
> AI 消费向项目卡(llms.txt v0.2.0-beta)。人用文档见 README.md;签名级 API 见 docs/sdk/API.md;MCP 服务器见 docs/sdk/MCP.md。
|
||||||
|
|
||||||
## 一句话
|
## 一句话
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ ppclock 是 4.2" 400×300 黑白红三色墨水屏设备(Dialog DA14585,广
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
||||||
.venv/bin/python -m pytest tests/ -q # 195 全绿
|
.venv/bin/python -m pytest tests/ -q # 208 全绿
|
||||||
```
|
```
|
||||||
|
|
||||||
- 库:`from ppclock import PPClient`
|
- 库:`from ppclock import PPClient`
|
||||||
@@ -42,10 +42,10 @@ MCP 挂载(agent 直挂 13 工具):`claude mcp add ppclock -- ppclock-mcp
|
|||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `src/ppclock/`:client.py(门面)/ protocol / image_pipeline / templates / transports(base,local,bridge) / firmware(image,ota) / cli / bridge_server / ppclock-mcp(mcp_server 入口 / device_manager 连接管理 / mcp_tools 13 工具)
|
- `src/ppclock/`:client.py(门面)/ protocol / image_pipeline / templates / transports(base,local,bridge) / firmware(image,ota) / cli / bridge_server / ppclock-mcp(mcp_server 入口 / device_manager 连接管理 / mcp_tools 13 工具)
|
||||||
- `tools/`:fw_info/fw_pack(镜像工具)、field_test(真机引导测试)、flash_run、gpio_probe、rppclock.sh(守候重试)
|
- `tools/`:fw_info/fw_pack(镜像工具)、field_test(真机引导测试)、flash_run、gpio_probe、rppclock.sh(守候重试)、mcp_windows_deploy.md(Windows 部署清单)
|
||||||
- `docs/`:protocol.md(协议规范·唯一事实源)、sdk/API.md、sdk/MCP.md、architecture.md、firmware-analysis.md、firmware-layout.md、field-test-result.md
|
- `docs/`:protocol.md(协议规范·唯一事实源)、sdk/API.md、sdk/MCP.md、architecture.md、firmware-analysis.md、firmware-layout.md、field-test-plan.md、field-test-result.md、superpowers/(specs+plans)
|
||||||
- `analysis/`:三线逆向证据(web/apk/firmware)
|
- `analysis/`:三线逆向证据(web/apk/firmware)
|
||||||
- `tests/`:195 测试
|
- `tests/`:208 测试
|
||||||
|
|
||||||
## 设备事实(实测 NRF-5DBF28)
|
## 设备事实(实测 NRF-5DBF28)
|
||||||
|
|
||||||
@@ -63,4 +63,4 @@ MCP 挂载(agent 直挂 13 工具):`claude mcp add ppclock -- ppclock-mcp
|
|||||||
|
|
||||||
## 版本
|
## 版本
|
||||||
|
|
||||||
0.2.0(CHANGELOG.md)。治理:GOVERNANCE.md(证据纪律/门禁);历史决策:DECISIONS.md。
|
0.2.0-beta(CHANGELOG.md)。治理:GOVERNANCE.md(证据纪律/门禁);历史决策:DECISIONS.md。
|
||||||
+5
-2
@@ -4,17 +4,20 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ppclock"
|
name = "ppclock"
|
||||||
version = "0.2.0"
|
version = "0.2.0b1"
|
||||||
description = "4.2寸墨水屏设备离线 CLI 上位机(DA14585 BLE,agent 友好)"
|
description = "4.2寸墨水屏设备离线 CLI 上位机(DA14585 BLE,agent 友好)"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bleak>=0.22",
|
"bleak>=0.22",
|
||||||
"pillow>=10",
|
"pillow>=10",
|
||||||
|
# 纯 Python 离线库,不违反零网络不变量;templates.py qrcode 模板硬依赖
|
||||||
|
"qrcode>=7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest>=8", "pytest-asyncio"]
|
dev = ["pytest>=8", "pytest-asyncio"]
|
||||||
mcp = ["mcp>=1.10,<2"]
|
# 地板 1.23:transport_security(--allowed-hosts 依赖)自 1.23 引入 + CVE-2025-66416 修复
|
||||||
|
mcp = ["mcp>=1.23,<2"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ppclock = "ppclock.cli:main"
|
ppclock = "ppclock.cli:main"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
androguard==4.1.4
|
androguard==4.1.4
|
||||||
bleak==3.0.2
|
bleak==3.0.2
|
||||||
capstone==5.0.9
|
capstone==5.0.9
|
||||||
|
# MCP 服务器依赖;钉版理由见 DECISIONS 2026-07-31;本文件为 GOVERNANCE 指定的依赖冻结
|
||||||
|
mcp==1.29.0
|
||||||
pillow==12.3.0
|
pillow==12.3.0
|
||||||
pytest==9.1.1
|
pytest==9.1.1
|
||||||
pytest-asyncio==1.4.0
|
pytest-asyncio==1.4.0
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
- bridge_server:BLE 桥服务端(ppclock-bridge 命令)
|
- bridge_server:BLE 桥服务端(ppclock-bridge 命令)
|
||||||
- device_manager / mcp_tools / mcp_server:MCP 服务器(ppclock-mcp 命令)
|
- device_manager / mcp_tools / mcp_server:MCP 服务器(ppclock-mcp 命令)
|
||||||
"""
|
"""
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.2.0b1"
|
||||||
|
|
||||||
from .client import PPClient
|
from .client import PPClient
|
||||||
from .transports.base import Transport, TransportError
|
from .transports.base import Transport, TransportError
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from . import commands as C
|
from . import commands as C
|
||||||
from . import image_pipeline as IP
|
from . import image_pipeline as IP
|
||||||
|
from . import protocol as P
|
||||||
from .transports import bridge as _bridge_mod
|
from .transports import bridge as _bridge_mod
|
||||||
from .transports import local as _local_mod
|
from .transports import local as _local_mod
|
||||||
from .transports.base import TransportError # noqa: F401 (SDK 公开异常)
|
from .transports.base import TransportError # noqa: F401 (SDK 公开异常)
|
||||||
@@ -31,6 +32,18 @@ class PPClient:
|
|||||||
def __init__(self, transport):
|
def __init__(self, transport):
|
||||||
self._t = transport
|
self._t = transport
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transport_info(self) -> dict:
|
||||||
|
"""返回传输层公开诊断信息;bridge/fake 等未提供字段时为空。"""
|
||||||
|
info = {}
|
||||||
|
firmware_revision = getattr(self._t, "firmware_revision", None)
|
||||||
|
rxtx_uuid = getattr(self._t, "rxtx_uuid", None)
|
||||||
|
if firmware_revision:
|
||||||
|
info["firmware_revision"] = firmware_revision
|
||||||
|
if rxtx_uuid:
|
||||||
|
info["rxtx_uuid"] = rxtx_uuid
|
||||||
|
return info
|
||||||
|
|
||||||
# ---------- 生命周期 ----------
|
# ---------- 生命周期 ----------
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
@@ -105,6 +118,11 @@ class PPClient:
|
|||||||
img = IP.Image.open(source) if isinstance(source, (str, Path)) else source
|
img = IP.Image.open(source) if isinstance(source, (str, Path)) else source
|
||||||
bw, red = IP.process_image(img, size=size, algorithm=algo,
|
bw, red = IP.process_image(img, size=size, algorithm=algo,
|
||||||
tricolor=not mono, **adjust)
|
tricolor=not mono, **adjust)
|
||||||
|
if red is None and P.requires_complete_image_planes(
|
||||||
|
getattr(self._t, "firmware_revision", None)):
|
||||||
|
# HM42 stores the two 400x300 planes as one sequential image. Its
|
||||||
|
# Web-BLE client sends an empty red plane even for monochrome input.
|
||||||
|
red = bytes(len(bw))
|
||||||
small = len(bw) in (3500, 9000)
|
small = len(bw) in (3500, 9000)
|
||||||
await C.upload_image(self._t, bw, red, small=small, slot=slot)
|
await C.upload_image(self._t, bw, red, small=small, slot=slot)
|
||||||
return {"bytes_bw": len(bw), "bytes_red": len(red) if red else 0,
|
return {"bytes_bw": len(bw), "bytes_red": len(red) if red else 0,
|
||||||
|
|||||||
+14
-3
@@ -34,10 +34,16 @@ async def clear(t, param: int = 0x00):
|
|||||||
|
|
||||||
async def upload_image(t, bw: bytes, red: bytes | None, *, small: bool,
|
async def upload_image(t, bw: bytes, red: bytes | None, *, small: bool,
|
||||||
slot: int | None = None, block_delay: float = 0.0):
|
slot: int | None = None, block_delay: float = 0.0):
|
||||||
"""传图:可选槽位选择 → bw 平面块 → 红平面块 → 300ms → 01/AA。"""
|
"""传图;HM42_AIO 使用厂商 App 的 EPD 会话,其他固件保留旧协议。"""
|
||||||
|
hm42_session = P.uses_hm42_image_session(
|
||||||
|
getattr(t, "firmware_revision", None)
|
||||||
|
)
|
||||||
if slot is not None:
|
if slot is not None:
|
||||||
await t.write_rxtx(P.slot_select_frame(slot))
|
await t.write_rxtx(P.slot_select_frame(slot))
|
||||||
await t.delay(0.3)
|
await t.delay(0.3)
|
||||||
|
if hm42_session:
|
||||||
|
for frame in P.hm42_image_begin_frames():
|
||||||
|
await t.write_epd(frame)
|
||||||
for frame in P.image_blocks(bw, small=small, red=False):
|
for frame in P.image_blocks(bw, small=small, red=False):
|
||||||
await t.write_epd(frame)
|
await t.write_epd(frame)
|
||||||
if block_delay:
|
if block_delay:
|
||||||
@@ -47,8 +53,13 @@ async def upload_image(t, bw: bytes, red: bytes | None, *, small: bool,
|
|||||||
await t.write_epd(frame)
|
await t.write_epd(frame)
|
||||||
if block_delay:
|
if block_delay:
|
||||||
await t.delay(block_delay)
|
await t.delay(block_delay)
|
||||||
await t.delay(0.3)
|
if hm42_session:
|
||||||
await t.write_epd(P.end_frame(small=small))
|
await t.write_epd(P.hm42_image_finish_frame())
|
||||||
|
await t.delay(0.3)
|
||||||
|
await t.write_rxtx(P.simple_command("commit"))
|
||||||
|
else:
|
||||||
|
await t.delay(0.3)
|
||||||
|
await t.write_epd(P.end_frame(small=small))
|
||||||
|
|
||||||
|
|
||||||
async def countdown_set(t, d: datetime.date, mode: str, prefix: str | None = None,
|
async def countdown_set(t, d: datetime.date, mode: str, prefix: str | None = None,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class DeviceManager:
|
|||||||
"connect_timeout": self._connect_timeout,
|
"connect_timeout": self._connect_timeout,
|
||||||
"idle_timeout": self._idle_timeout,
|
"idle_timeout": self._idle_timeout,
|
||||||
}
|
}
|
||||||
|
info.update(self._client.transport_info)
|
||||||
try:
|
try:
|
||||||
info["device_id"] = await self._client.get_device_id()
|
info["device_id"] = await self._client.get_device_id()
|
||||||
except Exception: # noqa: BLE001 - 状态查询尽力而为
|
except Exception: # noqa: BLE001 - 状态查询尽力而为
|
||||||
|
|||||||
+49
-2
@@ -8,13 +8,60 @@ from typing import Iterator
|
|||||||
EPD_SERVICE_UUID = "13187b10-eba9-a3ba-044e-83d3217d9a38"
|
EPD_SERVICE_UUID = "13187b10-eba9-a3ba-044e-83d3217d9a38"
|
||||||
EPD_CHAR_UUID = "4b646063-6264-f3a7-8941-e65356ea82fe"
|
EPD_CHAR_UUID = "4b646063-6264-f3a7-8941-e65356ea82fe"
|
||||||
RXTX_SERVICE_UUID = "00001f10-0000-1000-8000-00805f9b34fb"
|
RXTX_SERVICE_UUID = "00001f10-0000-1000-8000-00805f9b34fb"
|
||||||
RXTX_CHAR_UUID = "0000331f-0000-1000-8000-00805f9b34fb" # 活命令通道(h=57, notify+write+read)
|
RXTX_CHAR_UUID = "0000331f-0000-1000-8000-00805f9b34fb" # PP 4.2 活命令通道(h=57, notify+write+read)
|
||||||
RXTX_CHAR_UUID_LEGACY = "00001f1f-0000-1000-8000-00805f9b34fb" # h=53 仅write,本设备不执行命令
|
RXTX_CHAR_UUID_LEGACY = "00001f1f-0000-1000-8000-00805f9b34fb" # HM42_AIO 活命令通道(h=53, write-only)
|
||||||
|
FIRMWARE_REVISION_UUID = "00002a26-0000-1000-8000-00805f9b34fb"
|
||||||
DEVICE_NAME_PREFIX = "NRF-"
|
DEVICE_NAME_PREFIX = "NRF-"
|
||||||
|
|
||||||
BLOCK_PAYLOAD = 240 # my_step=480 hex 字符 = 240 数据字节
|
BLOCK_PAYLOAD = 240 # my_step=480 hex 字符 = 240 数据字节
|
||||||
COMMIT = 0xE2
|
COMMIT = 0xE2
|
||||||
|
|
||||||
|
|
||||||
|
def select_rxtx_uuid(firmware_revision: str | None) -> str:
|
||||||
|
"""按固件家族选择控制特征。
|
||||||
|
|
||||||
|
PP 4.2 实机的 1F1F 虽可写但不执行控制命令,活通道是 331F;
|
||||||
|
HM42_AIO 实机则与 Web-BLE / 厂商 App 一致,仅执行 1F1F。
|
||||||
|
未知或读不到 DIS 版本时保留既有 331F 行为,避免回归已验证的 PP 设备。
|
||||||
|
"""
|
||||||
|
revision = (firmware_revision or "").strip().upper()
|
||||||
|
if revision.startswith("HM42_AIO"):
|
||||||
|
return RXTX_CHAR_UUID_LEGACY
|
||||||
|
return RXTX_CHAR_UUID
|
||||||
|
|
||||||
|
|
||||||
|
def requires_complete_image_planes(firmware_revision: str | None) -> bool:
|
||||||
|
"""Whether a nominally mono full-screen upload still needs both planes.
|
||||||
|
|
||||||
|
The HM42 Web-BLE implementation always sends the 400x300 black/white plane
|
||||||
|
followed by a red plane before opcode 01. Omitting the second plane leaves
|
||||||
|
HM42 rendering stale/uninitialised red data (observed as a red noisy screen).
|
||||||
|
"""
|
||||||
|
revision = (firmware_revision or "").strip().upper()
|
||||||
|
return revision.startswith("HM42_AIO")
|
||||||
|
|
||||||
|
|
||||||
|
def uses_hm42_image_session(firmware_revision: str | None) -> bool:
|
||||||
|
"""Whether image transfer needs the newer HM42 EPD session framing.
|
||||||
|
|
||||||
|
The vendor Android provider ``轻舞/时光`` (``d8.e``) wraps both image
|
||||||
|
planes with ``8001, 0000, 020000`` and closes with ``0101`` followed by
|
||||||
|
RXTX ``e2`` for a normal (non-calendar) image. The older Web-BLE path has
|
||||||
|
no such preamble and ends with the legacy one-byte EPD frame.
|
||||||
|
"""
|
||||||
|
revision = (firmware_revision or "").strip().upper()
|
||||||
|
return revision.startswith("HM42_AIO")
|
||||||
|
|
||||||
|
|
||||||
|
def hm42_image_begin_frames() -> tuple[bytes, ...]:
|
||||||
|
"""Vendor-app EPD image-session preamble for HM42_AIO firmware."""
|
||||||
|
return (bytes.fromhex("8001"), bytes.fromhex("0000"), bytes.fromhex("020000"))
|
||||||
|
|
||||||
|
|
||||||
|
def hm42_image_finish_frame() -> bytes:
|
||||||
|
"""Vendor-app EPD image-session terminator for HM42_AIO firmware."""
|
||||||
|
return bytes.fromhex("0101")
|
||||||
|
|
||||||
MODES = {
|
MODES = {
|
||||||
**{f"image{i}": bytes([0xE1, 0x00, 0x00 + i]) for i in range(4)},
|
**{f"image{i}": bytes([0xE1, 0x00, 0x00 + i]) for i in range(4)},
|
||||||
"calendar1": bytes.fromhex("e10103"),
|
"calendar1": bytes.fromhex("e10103"),
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class BLETransport:
|
|||||||
self._client: BleakClient | None = None
|
self._client: BleakClient | None = None
|
||||||
self._id_buf = bytearray()
|
self._id_buf = bytearray()
|
||||||
self._id_event = asyncio.Event()
|
self._id_event = asyncio.Event()
|
||||||
|
self.firmware_revision: str | None = None
|
||||||
|
self.rxtx_uuid = P.RXTX_CHAR_UUID
|
||||||
|
self._notify_started = False
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
await self.connect()
|
await self.connect()
|
||||||
@@ -54,13 +57,37 @@ class BLETransport:
|
|||||||
await self._client.connect()
|
await self._client.connect()
|
||||||
except BleakError as e:
|
except BleakError as e:
|
||||||
raise TransportError(f"连接失败: {e}") from e
|
raise TransportError(f"连接失败: {e}") from e
|
||||||
await self._client.start_notify(P.RXTX_CHAR_UUID, self._on_notify)
|
try:
|
||||||
|
raw_revision = await self._client.read_gatt_char(P.FIRMWARE_REVISION_UUID)
|
||||||
|
self.firmware_revision = bytes(raw_revision).decode(
|
||||||
|
"utf-8", errors="replace").rstrip("\x00")
|
||||||
|
except Exception: # DIS 版本是兼容性提示,不应阻断基础连接
|
||||||
|
self.firmware_revision = None
|
||||||
|
|
||||||
|
preferred = P.select_rxtx_uuid(self.firmware_revision)
|
||||||
|
alternate = (
|
||||||
|
P.RXTX_CHAR_UUID_LEGACY
|
||||||
|
if preferred == P.RXTX_CHAR_UUID else P.RXTX_CHAR_UUID
|
||||||
|
)
|
||||||
|
selected = self._client.services.get_characteristic(preferred)
|
||||||
|
if selected is None:
|
||||||
|
selected = self._client.services.get_characteristic(alternate)
|
||||||
|
if selected is None:
|
||||||
|
raise TransportError("未找到 RXTX 控制特征 1F1F/331F")
|
||||||
|
self.rxtx_uuid = selected.uuid
|
||||||
|
|
||||||
|
properties = set(selected.properties)
|
||||||
|
if properties.intersection({"notify", "indicate"}):
|
||||||
|
await self._client.start_notify(selected, self._on_notify)
|
||||||
|
self._notify_started = True
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
if self._client and self._client.is_connected:
|
if self._client and self._client.is_connected:
|
||||||
try:
|
try:
|
||||||
await self._client.stop_notify(P.RXTX_CHAR_UUID)
|
if self._notify_started:
|
||||||
|
await self._client.stop_notify(self.rxtx_uuid)
|
||||||
finally:
|
finally:
|
||||||
|
self._notify_started = False
|
||||||
await self._client.disconnect()
|
await self._client.disconnect()
|
||||||
|
|
||||||
def _on_notify(self, _sender, data: bytearray):
|
def _on_notify(self, _sender, data: bytearray):
|
||||||
@@ -72,12 +99,17 @@ class BLETransport:
|
|||||||
await self._client.write_gatt_char(P.EPD_CHAR_UUID, data, response=response)
|
await self._client.write_gatt_char(P.EPD_CHAR_UUID, data, response=response)
|
||||||
|
|
||||||
async def write_rxtx(self, data: bytes, response: bool = True):
|
async def write_rxtx(self, data: bytes, response: bool = True):
|
||||||
await self._client.write_gatt_char(P.RXTX_CHAR_UUID, data, response=response)
|
await self._client.write_gatt_char(self.rxtx_uuid, data, response=response)
|
||||||
|
|
||||||
async def read_rxtx(self) -> bytes:
|
async def read_rxtx(self) -> bytes:
|
||||||
return bytes(await self._client.read_gatt_char(P.RXTX_CHAR_UUID))
|
return bytes(await self._client.read_gatt_char(self.rxtx_uuid))
|
||||||
|
|
||||||
async def request_device_id(self, timeout: float = 8.0) -> str:
|
async def request_device_id(self, timeout: float = 8.0) -> str:
|
||||||
|
if not self._notify_started:
|
||||||
|
raise TransportError(
|
||||||
|
f"固件 {self.firmware_revision or 'unknown'} 的控制特征不支持通知,"
|
||||||
|
"无法读取设备 ID"
|
||||||
|
)
|
||||||
self._id_buf.clear()
|
self._id_buf.clear()
|
||||||
self._id_event.clear()
|
self._id_event.clear()
|
||||||
await self.write_rxtx(P.request_id_frame())
|
await self.write_rxtx(P.request_id_frame())
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from ppclock.transports import bridge
|
|||||||
|
|
||||||
|
|
||||||
class FakeBridgeServer:
|
class FakeBridgeServer:
|
||||||
"""模拟 tools/bridge_server.py 的 JSONL 协议。"""
|
"""模拟 ppclock.bridge_server 的 JSONL 协议。"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.received = []
|
self.received = []
|
||||||
|
|||||||
+36
-1
@@ -7,10 +7,11 @@ from ppclock.client import PPClient
|
|||||||
|
|
||||||
|
|
||||||
class FakeTransport:
|
class FakeTransport:
|
||||||
def __init__(self):
|
def __init__(self, firmware_revision=None):
|
||||||
self.epd_writes = []
|
self.epd_writes = []
|
||||||
self.rxtx_writes = []
|
self.rxtx_writes = []
|
||||||
self.address = "AA:BB:CC:DD:EE:FF"
|
self.address = "AA:BB:CC:DD:EE:FF"
|
||||||
|
self.firmware_revision = firmware_revision
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
@@ -78,6 +79,40 @@ class TestCommands:
|
|||||||
assert client._t.rxtx_writes[0] == bytes([0xEA, 0x00])
|
assert client._t.rxtx_writes[0] == bytes([0xEA, 0x00])
|
||||||
assert len(client._t.epd_writes) == 63 + 63 + 1
|
assert len(client._t.epd_writes) == 63 + 63 + 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_mono_non_hm42_sends_one_plane(self):
|
||||||
|
from PIL import Image
|
||||||
|
client = PPClient(FakeTransport("PP_da14585_4.2"))
|
||||||
|
result = await client.upload_image(
|
||||||
|
Image.new("RGB", (400, 300), "white"), slot=1,
|
||||||
|
algo="none", mono=True,
|
||||||
|
)
|
||||||
|
assert result["bytes_bw"] == 15000
|
||||||
|
assert result["bytes_red"] == 0
|
||||||
|
assert len(client._t.epd_writes) == 63 + 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_mono_hm42_appends_empty_red_plane(self):
|
||||||
|
from PIL import Image
|
||||||
|
client = PPClient(FakeTransport("HM42_AIO_V1.0.9"))
|
||||||
|
result = await client.upload_image(
|
||||||
|
Image.new("RGB", (400, 300), "white"), slot=1,
|
||||||
|
algo="none", mono=True,
|
||||||
|
)
|
||||||
|
assert result["bytes_bw"] == 15000
|
||||||
|
assert result["bytes_red"] == 15000
|
||||||
|
assert client._t.epd_writes[:3] == [
|
||||||
|
bytes.fromhex("8001"),
|
||||||
|
bytes.fromhex("0000"),
|
||||||
|
bytes.fromhex("020000"),
|
||||||
|
]
|
||||||
|
assert len(client._t.epd_writes) == 3 + 63 + 63 + 1
|
||||||
|
first_red = client._t.epd_writes[3 + 63]
|
||||||
|
assert first_red[:4] == bytes.fromhex("03000000")
|
||||||
|
assert set(first_red[4:]) == {0}
|
||||||
|
assert client._t.epd_writes[-1] == bytes.fromhex("0101")
|
||||||
|
assert client._t.rxtx_writes[-1] == bytes.fromhex("e2")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_render_template(self, client):
|
async def test_render_template(self, client):
|
||||||
result = await client.render_template("custom", {"text": "你好"})
|
result = await client.render_template("custom", {"text": "你好"})
|
||||||
|
|||||||
+19
-1
@@ -7,10 +7,11 @@ from ppclock import commands as C
|
|||||||
|
|
||||||
|
|
||||||
class FakeTransport:
|
class FakeTransport:
|
||||||
def __init__(self, id_response="DEVICE12345678"):
|
def __init__(self, id_response="DEVICE12345678", firmware_revision=None):
|
||||||
self.epd_writes = []
|
self.epd_writes = []
|
||||||
self.rxtx_writes = []
|
self.rxtx_writes = []
|
||||||
self.id_response = id_response
|
self.id_response = id_response
|
||||||
|
self.firmware_revision = firmware_revision
|
||||||
self.delays = []
|
self.delays = []
|
||||||
|
|
||||||
async def write_epd(self, data, response=True):
|
async def write_epd(self, data, response=True):
|
||||||
@@ -80,6 +81,23 @@ class TestUploadImage:
|
|||||||
assert t.epd_writes[-1] == bytes([0xAA])
|
assert t.epd_writes[-1] == bytes([0xAA])
|
||||||
assert t.epd_writes[0][0] == 0x04
|
assert t.epd_writes[0][0] == 0x04
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hm42_uses_vendor_epd_session(self):
|
||||||
|
t = FakeTransport(firmware_revision="HM42_AIO_V1.0.9")
|
||||||
|
await C.upload_image(
|
||||||
|
t, bytes(15000), bytes(15000), small=False, slot=1
|
||||||
|
)
|
||||||
|
assert t.rxtx_writes == [bytes.fromhex("EA01"), bytes.fromhex("E2")]
|
||||||
|
assert t.epd_writes[:3] == [
|
||||||
|
bytes.fromhex("8001"),
|
||||||
|
bytes.fromhex("0000"),
|
||||||
|
bytes.fromhex("020000"),
|
||||||
|
]
|
||||||
|
assert t.epd_writes[3][:4] == bytes.fromhex("03FF0000")
|
||||||
|
assert t.epd_writes[66][:4] == bytes.fromhex("03000000")
|
||||||
|
assert t.epd_writes[-1] == bytes.fromhex("0101")
|
||||||
|
assert len(t.epd_writes) == 3 + 63 + 63 + 1
|
||||||
|
|
||||||
|
|
||||||
class TestOtherCommands:
|
class TestOtherCommands:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class FakeTransport:
|
|||||||
# fail_next_write 触发时抛出的异常实例(默认 SDK 包装后的 TransportError;
|
# fail_next_write 触发时抛出的异常实例(默认 SDK 包装后的 TransportError;
|
||||||
# 真实硬件路径常是 bleak 裸抛的 BleakError,测试可替换)
|
# 真实硬件路径常是 bleak 裸抛的 BleakError,测试可替换)
|
||||||
self.fail_exc = TransportError("connection lost")
|
self.fail_exc = TransportError("connection lost")
|
||||||
|
self.firmware_revision = "HM42_AIO_V1.0.9"
|
||||||
|
self.rxtx_uuid = "00001f1f-0000-1000-8000-00805f9b34fb"
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
self.enters += 1
|
self.enters += 1
|
||||||
@@ -204,6 +206,8 @@ class TestStatus:
|
|||||||
assert s["connect_timeout"] == 90.0
|
assert s["connect_timeout"] == 90.0
|
||||||
assert s["idle_timeout"] == 300.0
|
assert s["idle_timeout"] == 300.0
|
||||||
assert s["idle_seconds"] is None # 无先前活动;status 自身不得把读数归零
|
assert s["idle_seconds"] is None # 无先前活动;status 自身不得把读数归零
|
||||||
|
assert s["firmware_revision"] == "HM42_AIO_V1.0.9"
|
||||||
|
assert s["rxtx_uuid"].startswith("00001f1f")
|
||||||
await m.close()
|
await m.close()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -61,6 +61,29 @@ class TestSetTime:
|
|||||||
|
|
||||||
|
|
||||||
class TestCommandFrames:
|
class TestCommandFrames:
|
||||||
|
def test_rxtx_channel_by_firmware_family(self):
|
||||||
|
assert P.select_rxtx_uuid("HM42_AIO_V1.0.9") == P.RXTX_CHAR_UUID_LEGACY
|
||||||
|
assert P.select_rxtx_uuid("hm42_aio_v2") == P.RXTX_CHAR_UUID_LEGACY
|
||||||
|
assert P.select_rxtx_uuid("PP_da14585_4.2") == P.RXTX_CHAR_UUID
|
||||||
|
assert P.select_rxtx_uuid(None) == P.RXTX_CHAR_UUID
|
||||||
|
|
||||||
|
def test_complete_image_planes_by_firmware_family(self):
|
||||||
|
assert P.requires_complete_image_planes("HM42_AIO_V1.0.9") is True
|
||||||
|
assert P.requires_complete_image_planes("hm42_aio_v2") is True
|
||||||
|
assert P.requires_complete_image_planes("PP_da14585_4.2") is False
|
||||||
|
assert P.requires_complete_image_planes(None) is False
|
||||||
|
|
||||||
|
def test_hm42_image_session_by_firmware_family(self):
|
||||||
|
assert P.uses_hm42_image_session("HM42_AIO_V1.0.9") is True
|
||||||
|
assert P.uses_hm42_image_session("PP_da14585_4.2") is False
|
||||||
|
assert P.uses_hm42_image_session(None) is False
|
||||||
|
assert P.hm42_image_begin_frames() == (
|
||||||
|
bytes.fromhex("8001"),
|
||||||
|
bytes.fromhex("0000"),
|
||||||
|
bytes.fromhex("020000"),
|
||||||
|
)
|
||||||
|
assert P.hm42_image_finish_frame() == bytes.fromhex("0101")
|
||||||
|
|
||||||
def test_parking(self):
|
def test_parking(self):
|
||||||
"""停车牌 "12" → EF1122(向量#4)"""
|
"""停车牌 "12" → EF1122(向量#4)"""
|
||||||
assert P.parking_frame("12") == bytes([0xEF, 0x11, 0x22])
|
assert P.parking_frame("12") == bytes([0xEF, 0x11, 0x22])
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""本机 BLE 传输层的固件通道选择测试。"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ppclock import protocol as P
|
||||||
|
from ppclock.transports import local
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCharacteristic:
|
||||||
|
def __init__(self, uuid, properties):
|
||||||
|
self.uuid = uuid
|
||||||
|
self.properties = properties
|
||||||
|
|
||||||
|
|
||||||
|
class FakeServices:
|
||||||
|
def __init__(self):
|
||||||
|
self.characters = {
|
||||||
|
P.RXTX_CHAR_UUID_LEGACY: FakeCharacteristic(
|
||||||
|
P.RXTX_CHAR_UUID_LEGACY, ["write"]),
|
||||||
|
P.RXTX_CHAR_UUID: FakeCharacteristic(
|
||||||
|
P.RXTX_CHAR_UUID, ["read", "write", "notify"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_characteristic(self, uuid):
|
||||||
|
return self.characters.get(str(uuid).lower())
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBleakClient:
|
||||||
|
firmware_revision = "PP_da14585_4.2"
|
||||||
|
fail_firmware_read = False
|
||||||
|
|
||||||
|
def __init__(self, address, timeout):
|
||||||
|
self.address = address
|
||||||
|
self.timeout = timeout
|
||||||
|
self.services = FakeServices()
|
||||||
|
self.is_connected = False
|
||||||
|
self.notifications = []
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
self.is_connected = True
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
self.is_connected = False
|
||||||
|
|
||||||
|
async def read_gatt_char(self, uuid):
|
||||||
|
if str(uuid).lower() == P.FIRMWARE_REVISION_UUID:
|
||||||
|
if self.fail_firmware_read:
|
||||||
|
raise RuntimeError("DIS unavailable")
|
||||||
|
return self.firmware_revision.encode()
|
||||||
|
return b""
|
||||||
|
|
||||||
|
async def write_gatt_char(self, uuid, data, response=True):
|
||||||
|
target = uuid.uuid if hasattr(uuid, "uuid") else uuid
|
||||||
|
self.writes.append((target, bytes(data), response))
|
||||||
|
|
||||||
|
async def start_notify(self, uuid, callback):
|
||||||
|
target = uuid.uuid if hasattr(uuid, "uuid") else uuid
|
||||||
|
self.notifications.append(target)
|
||||||
|
|
||||||
|
async def stop_notify(self, uuid):
|
||||||
|
self.notifications.remove(uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hm42_selects_write_only_1f1f(monkeypatch):
|
||||||
|
class HM42Client(FakeBleakClient):
|
||||||
|
firmware_revision = "HM42_AIO_V1.0.9"
|
||||||
|
|
||||||
|
monkeypatch.setattr(local, "BleakClient", HM42Client)
|
||||||
|
transport = local.BLETransport("18:BC:5A:7E:BB:1E")
|
||||||
|
await transport.connect()
|
||||||
|
|
||||||
|
assert transport.firmware_revision == "HM42_AIO_V1.0.9"
|
||||||
|
assert transport.rxtx_uuid == P.RXTX_CHAR_UUID_LEGACY
|
||||||
|
assert not transport._notify_started
|
||||||
|
await transport.write_rxtx(b"\xe1\x02\x02")
|
||||||
|
assert transport._client.writes[-1][0] == P.RXTX_CHAR_UUID_LEGACY
|
||||||
|
with pytest.raises(local.TransportError, match="不支持通知"):
|
||||||
|
await transport.request_device_id()
|
||||||
|
await transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pp42_keeps_331f_with_notifications(monkeypatch):
|
||||||
|
monkeypatch.setattr(local, "BleakClient", FakeBleakClient)
|
||||||
|
transport = local.BLETransport("18:BC:5A:5D:BF:28")
|
||||||
|
await transport.connect()
|
||||||
|
|
||||||
|
assert transport.rxtx_uuid == P.RXTX_CHAR_UUID
|
||||||
|
assert transport._notify_started
|
||||||
|
assert transport._client.notifications == [P.RXTX_CHAR_UUID]
|
||||||
|
await transport.write_rxtx(b"\xe2")
|
||||||
|
assert transport._client.writes[-1][0] == P.RXTX_CHAR_UUID
|
||||||
|
await transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_firmware_preserves_331f_default(monkeypatch):
|
||||||
|
class NoDISClient(FakeBleakClient):
|
||||||
|
fail_firmware_read = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(local, "BleakClient", NoDISClient)
|
||||||
|
transport = local.BLETransport("AA:BB:CC:DD:EE:FF")
|
||||||
|
await transport.connect()
|
||||||
|
|
||||||
|
assert transport.firmware_revision is None
|
||||||
|
assert transport.rxtx_uuid == P.RXTX_CHAR_UUID
|
||||||
|
await transport.close()
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# ppclock-mcp · Windows 部署执行清单(熠管家协作版)
|
# ppclock-mcp · Windows 部署执行清单(熠管家协作版)
|
||||||
|
|
||||||
|
> 时点件(2026-07-30 测试期清单)。现行部署形态以 docs/sdk/MCP.md §常驻部署 为准。
|
||||||
|
|
||||||
目标:把 ppclock-mcp 部署到**蓝牙物理所在的 Windows 服务器**,跑通 stdio 与 HTTP 两种接入实测。
|
目标:把 ppclock-mcp 部署到**蓝牙物理所在的 Windows 服务器**,跑通 stdio 与 HTTP 两种接入实测。
|
||||||
约束:**不动** 8971 bridge 与该机任何既有服务;8972 防火墙仅局域网放行;测试期手动启动,不注册系统服务。
|
约束:**不动** 8971 bridge 与该机任何既有服务;8972 防火墙仅局域网放行;测试期手动启动,不注册系统服务。
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ python --version # 需 ≥3.10
|
|||||||
```powershell
|
```powershell
|
||||||
git clone <仓库地址> C:\ppclock # 或把 Linux 侧源码压缩包拷贝解压到 C:\ppclock
|
git clone <仓库地址> C:\ppclock # 或把 Linux 侧源码压缩包拷贝解压到 C:\ppclock
|
||||||
cd C:\ppclock
|
cd C:\ppclock
|
||||||
git checkout feat/ppclock-mcp # 分支合并 main 后此步改为 git checkout main
|
git checkout main # 对应 tag v0.2.0-beta
|
||||||
git log --oneline -1 # 记录 commit,回报用
|
git log --oneline -1 # 记录 commit,回报用
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ python -m venv .venv
|
|||||||
```powershell
|
```powershell
|
||||||
.\.venv\Scripts\ppclock-mcp --help
|
.\.venv\Scripts\ppclock-mcp --help
|
||||||
```
|
```
|
||||||
预期:打印参数表,含 `--transport/--host/--port/--mac/--connect-timeout/--idle-timeout`。
|
预期:打印参数表,含 `--transport/--host/--port/--mac/--connect-timeout/--idle-timeout/--allowed-hosts`。
|
||||||
|
|
||||||
## 4. stdio 实测(本机 agent 直挂)
|
## 4. stdio 实测(本机 agent 直挂)
|
||||||
|
|
||||||
@@ -60,7 +62,8 @@ claude mcp add ppclock -- C:\ppclock\.venv\Scripts\ppclock-mcp.exe --mac <MAC>
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:PPCLOCK_MCP_TOKEN = "<随机长串>"
|
$env:PPCLOCK_MCP_TOKEN = "<随机长串>"
|
||||||
.\.venv\Scripts\ppclock-mcp --transport http --host 0.0.0.0 --port 8972 --mac <MAC>
|
.\.venv\Scripts\ppclock-mcp --transport http --host 0.0.0.0 --port 8972 --mac <MAC> `
|
||||||
|
--allowed-hosts "192.168.61.35:8972,localhost:8972,127.0.0.1:8972" # 换成本机局域网地址;缺了 LAN Host 请求被 DNS 重绑定防护 421
|
||||||
```
|
```
|
||||||
另开终端:
|
另开终端:
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
Reference in new issue
Block a user