Compare commits
7
Commits
3ce4fc0f0a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
578cd7fa20 | ||
|
|
2919ebe312 | ||
|
|
092b104e3d | ||
|
|
3db8295654 | ||
|
|
da02bd31c6 | ||
|
|
34dd6b4c52 | ||
|
|
faf8db4cbb |
No files matched your search
@@ -1,5 +1,22 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## [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
|
## [0.2.0-beta] — 2026-07-31
|
||||||
|
|
||||||
新增 **ppclock-mcp**:跑在蓝牙物理所在机器上的单层 MCP 服务器,agent 经 stdio 或
|
新增 **ppclock-mcp**:跑在蓝牙物理所在机器上的单层 MCP 服务器,agent 经 stdio 或
|
||||||
|
|||||||
+4
-2
@@ -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')缺失/无效,见下条 |
|
||||||
@@ -29,4 +29,6 @@
|
|||||||
| 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 | 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 | `--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-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)、大文件仅原始输入随库 |
|
| 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,便于运维审计 |
|
||||||
+19
-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,8 +70,10 @@ 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)
|
## 6. Git 与远端协作(2026-08 增补,DECISIONS 2026-08-01)
|
||||||
|
|||||||
@@ -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** 断网验收 |
|
||||||
|
|||||||
+26
-1
@@ -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(首日快照)
|
||||||
|
|
||||||
### 已完成(对照门禁)
|
### 已完成(对照门禁)
|
||||||
|
|
||||||
@@ -244,3 +248,24 @@
|
|||||||
- 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;屏显目视归用户
|
- 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 起稳定至今)——运维关注,非代码缺陷结论待续观
|
- 观察项: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-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、pytest-asyncio)
|
.venv/bin/pip install -e ".[dev]" # 依赖:bleak、pillow、qrcode(dev: pytest、pytest-asyncio)
|
||||||
```
|
```
|
||||||
|
|
||||||
运行要求:蓝牙适配器(本机 bleak)**或** 他机蓝牙经 `ppclock-bridge` 桥接。**不需要互联网**。
|
运行要求:蓝牙适配器(本机 bleak)**或** 他机蓝牙经 `ppclock-bridge` 桥接。**不需要互联网**。
|
||||||
|
|||||||
+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. 本地化(去云)清单 —— 已全部解决
|
||||||
|
|
||||||
| 云依赖 | 本地化方案 | 状态 |
|
| 云依赖 | 本地化方案 | 状态 |
|
||||||
|
|||||||
+1
-1
@@ -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 |
|
||||||
|
|
||||||
### 倒计时与文字
|
### 倒计时与文字
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
+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()
|
||||||
Reference in new issue
Block a user