Compare commits

...
19 changed files with 391 additions and 28 deletions

No files matched your search

+17
View File
@@ -1,5 +1,22 @@
# 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
新增 **ppclock-mcp**:跑在蓝牙物理所在机器上的单层 MCP 服务器,agent 经 stdio 或
+4 -2
View File
@@ -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 | **设备 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-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 | **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')缺失/无效,见下条 |
@@ -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 | `--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)、大文件仅原始输入随库 |
| 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
View File
@@ -18,25 +18,35 @@ qianbian/
├── PLAN.md # 总体规划与阶段验收标准(指向 docs/superpowers/plans/)
├── PROGRESS.md # 实时进度日志:每次工作会话结束必须更新
├── DECISIONS.md # 决策日志:每条决策含日期、背景、结论、理由
├── CHANGELOG.md # 产品变更记录(release 口径)
├── llms.txt # AI 消费向项目卡(随发布同步)
├── app/ # 原始输入物(APK、固件,只读,不得修改)
├── docs/ # 所有分析文档
│ ├── architecture.md # 软硬件架构总述
│ ├── protocol.md # BLE 协议规范(最终单一事实来源)
│ ├── 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/ # 逆向中间产物(反编译输出、脚本、抓包等)
│ ├── apk/ # APK 反编译产物(不入库的大文件除外)
│ ├── firmware/ # 固件反汇编/提取产物
│ └── web/ # Web 上位机抓取产物
├── src/ppclock/ # 本地 CLI 上位机(交付物)
├── tools/ # 固件处理等辅助工具(交付物)
└── tests/ # CLI 与工具的自动化测试
├── src/ppclock/ # 交付物:SDK+CLI(PPClient/ppclock)
│ ├── transports/ # base/local(bleak)/bridge(RPC) 传输层
│ ├── 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/`。
- 每个分析结论必须写明证据(文件偏移 / 代码位置 / 抓包数据),禁止无证据断言。
- 交付物只有两类:`src/ppclock`(CLI)与 `tools/`(固件工具)+ `docs/`(文档)。
- 交付物:`src/ppclock`(SDK+CLI+bridge+MCP)、`tools/`、`examples/`、`docs/`。
## 3. 验证门禁(每个阶段必须满足才算完成)
@@ -60,8 +70,10 @@ qianbian/
## 5. 工具与环境
- 分析环境:Python 3.12(venv 于 `.venv/`)、jadx、capstone、bleak。
- 依赖锁定:`requirements.txt` 固定版本;新增依赖需记录于 DECISIONS.md。
- 分析环境:Python 3.12(venv 于 `.venv/`)、androguard(APK)、capstone(固件反汇编)、bleak(BLE)。
(注: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 -1
View File
@@ -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** 三线互证一致 |
| P2 CLI 上位机 | 协议层/图像管线/命令层/CLI,功能对齐 Web 上位机 | 目标2 | **G2** 测试全绿+JSON schema;**G3** 功能全覆盖 |
| P3 增强+固件可控 | 本地模板渲染/批量/调度;固件解析重打包+SUOTA | 目标2增强、目标3 | **G4** 镜像可改可重生成;**G5** 断网验收 |
+26 -1
View File
@@ -1,6 +1,10 @@
# 进度日志 (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;屏显目视归用户
- 观察项: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 返回值单独算作上屏成功。
+1 -1
View File
@@ -12,7 +12,7 @@ Python SDK(`PPClient`)+ CLI(`ppclock`)+ BLE 桥(`ppclock-bridge`),
```bash
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` 桥接。**不需要互联网**。
+11 -2
View File
@@ -131,12 +131,21 @@ ASCII UTF-8 直发:`WIFI_SSID:{ssid};WIFI_PASS:{pass};CITY:{城市代码}`(
## 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。
3. 对时:`dd…` + `e2`。
4. 操作:模式切换(+`e2`)/ 传图(`EA0{slot}` → 数据块×N → `01`/`AA`)/ 倒计时 / WiFi。
4. 操作:模式切换(+`e2`)/ 传图 / 倒计时 / WiFi。
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. 本地化(去云)清单 —— 已全部解决
| 云依赖 | 本地化方案 | 状态 |
+1 -1
View File
@@ -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 |
### 倒计时与文字
@@ -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 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
---
+18
View File
@@ -19,6 +19,7 @@ from pathlib import Path
from . import commands as C
from . import image_pipeline as IP
from . import protocol as P
from .transports import bridge as _bridge_mod
from .transports import local as _local_mod
from .transports.base import TransportError # noqa: F401 (SDK 公开异常)
@@ -31,6 +32,18 @@ class PPClient:
def __init__(self, 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):
@@ -105,6 +118,11 @@ class PPClient:
img = IP.Image.open(source) if isinstance(source, (str, Path)) else source
bw, red = IP.process_image(img, size=size, algorithm=algo,
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)
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,
+14 -3
View File
@@ -34,10 +34,16 @@ async def clear(t, param: int = 0x00):
async def upload_image(t, bw: bytes, red: bytes | None, *, small: bool,
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:
await t.write_rxtx(P.slot_select_frame(slot))
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):
await t.write_epd(frame)
if block_delay:
@@ -47,8 +53,13 @@ async def upload_image(t, bw: bytes, red: bytes | None, *, small: bool,
await t.write_epd(frame)
if block_delay:
await t.delay(block_delay)
await t.delay(0.3)
await t.write_epd(P.end_frame(small=small))
if hm42_session:
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,
+1
View File
@@ -67,6 +67,7 @@ class DeviceManager:
"connect_timeout": self._connect_timeout,
"idle_timeout": self._idle_timeout,
}
info.update(self._client.transport_info)
try:
info["device_id"] = await self._client.get_device_id()
except Exception: # noqa: BLE001 - 状态查询尽力而为
+49 -2
View File
@@ -8,13 +8,60 @@ from typing import Iterator
EPD_SERVICE_UUID = "13187b10-eba9-a3ba-044e-83d3217d9a38"
EPD_CHAR_UUID = "4b646063-6264-f3a7-8941-e65356ea82fe"
RXTX_SERVICE_UUID = "00001f10-0000-1000-8000-00805f9b34fb"
RXTX_CHAR_UUID = "0000331f-0000-1000-8000-00805f9b34fb" # 活命令通道(h=57, notify+write+read)
RXTX_CHAR_UUID_LEGACY = "00001f1f-0000-1000-8000-00805f9b34fb" # h=53 仅write,本设备不执行命令
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" # HM42_AIO 活命令通道(h=53, write-only)
FIRMWARE_REVISION_UUID = "00002a26-0000-1000-8000-00805f9b34fb"
DEVICE_NAME_PREFIX = "NRF-"
BLOCK_PAYLOAD = 240 # my_step=480 hex 字符 = 240 数据字节
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 = {
**{f"image{i}": bytes([0xE1, 0x00, 0x00 + i]) for i in range(4)},
"calendar1": bytes.fromhex("e10103"),
+36 -4
View File
@@ -35,6 +35,9 @@ class BLETransport:
self._client: BleakClient | None = None
self._id_buf = bytearray()
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):
await self.connect()
@@ -54,13 +57,37 @@ class BLETransport:
await self._client.connect()
except BleakError as 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):
if self._client and self._client.is_connected:
try:
await self._client.stop_notify(P.RXTX_CHAR_UUID)
if self._notify_started:
await self._client.stop_notify(self.rxtx_uuid)
finally:
self._notify_started = False
await self._client.disconnect()
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)
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:
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:
if not self._notify_started:
raise TransportError(
f"固件 {self.firmware_revision or 'unknown'} 的控制特征不支持通知,"
"无法读取设备 ID"
)
self._id_buf.clear()
self._id_event.clear()
await self.write_rxtx(P.request_id_frame())
+36 -1
View File
@@ -7,10 +7,11 @@ from ppclock.client import PPClient
class FakeTransport:
def __init__(self):
def __init__(self, firmware_revision=None):
self.epd_writes = []
self.rxtx_writes = []
self.address = "AA:BB:CC:DD:EE:FF"
self.firmware_revision = firmware_revision
async def __aenter__(self):
return self
@@ -78,6 +79,40 @@ class TestCommands:
assert client._t.rxtx_writes[0] == bytes([0xEA, 0x00])
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
async def test_render_template(self, client):
result = await client.render_template("custom", {"text": "你好"})
+19 -1
View File
@@ -7,10 +7,11 @@ from ppclock import commands as C
class FakeTransport:
def __init__(self, id_response="DEVICE12345678"):
def __init__(self, id_response="DEVICE12345678", firmware_revision=None):
self.epd_writes = []
self.rxtx_writes = []
self.id_response = id_response
self.firmware_revision = firmware_revision
self.delays = []
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[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:
@pytest.mark.asyncio
+4
View File
@@ -19,6 +19,8 @@ class FakeTransport:
# fail_next_write 触发时抛出的异常实例(默认 SDK 包装后的 TransportError;
# 真实硬件路径常是 bleak 裸抛的 BleakError,测试可替换)
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):
self.enters += 1
@@ -204,6 +206,8 @@ class TestStatus:
assert s["connect_timeout"] == 90.0
assert s["idle_timeout"] == 300.0
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()
@pytest.mark.asyncio
+23
View File
@@ -61,6 +61,29 @@ class TestSetTime:
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):
"""停车牌 "12" → EF1122(向量#4)"""
assert P.parking_frame("12") == bytes([0xEF, 0x11, 0x22])
+109
View File
@@ -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()