diff --git a/.gitignore b/.gitignore index 4aa6006..beed3d6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,10 @@ SerialMate/release/serial_mate.json SerialMate/src/board_profiles.json SerialMate/release/board_profiles.json +# I2C-Lab 运行态配置不入库; 固件构建产物在 firmware/build/(build/ 规则已覆盖) +# release/ 交付物(exe/UF2/BIN/zip)不入库: 本地保留, 待真机验收发布时再走流水线 +I2C-Lab/src/i2c_lab.json +I2C-Lab/release/ + # ZCode 客户端本地会话数据(计划留档等), 非项目配置 .zcode/ diff --git a/I2C-Inject/README.md b/I2C-Inject/README.md index b789bb2..851d7cd 100644 --- a/I2C-Inject/README.md +++ b/I2C-Inject/README.md @@ -1,8 +1,13 @@ # I2C-Inject — LT8619C 寄存器择机注入工具 -> **⏸ 状态:暂停开发(2026-09-25)。** 工具与固件保留在仓库中不作删除,当前没有继续开发的计划。 -> demo 板寄存器语义实测当时即由本工具完成;0x606E 其余位交换经确认(2026-09-25)现状正常、 -> 符合预期,无需单独实测。恢复开发前请勿视为完整可用。 +> **⏸ 状态:暂停开发(2026-09-25),后续开发并入 I2C-Lab(2026-10-02)。** +> 工具与固件保留在仓库中不作删除;本目录转为留档。完整的 I2C 调试台 +> (纯被动监控 → 主机读写/择机注入 → 从机模拟)在 [`I2C-Lab/`](../I2C-Lab/) +> 继续:监控阶段 v0.1 已就绪(PIO 硬件捕获 + 实时事务流 + 波形快照), +> 本工具的"择机注入"职能将在其主机阶段复活(接线 GP8/SDA、GP9/SCL 与 +> `=`/`!` 回显约定不变)。 +> demo 板寄存器语义实测当时即由本工具完成;0x606E 其余位交换经确认(2026-09-25) +> 现状正常、符合预期,无需单独实测。恢复开发前请勿视为完整可用。 > 更新:2026-09-03。由两部分组成:**RP2040 注入器固件**(`mcu-rp2040/`,刷入 > BPI-Pico-RP2040,MicroPython)+ **PC 端 GUI**(`i2c_inject_gui.py` / 打包版 diff --git a/I2C-Lab/AGENTS.md b/I2C-Lab/AGENTS.md new file mode 100644 index 0000000..232ca9c --- /dev/null +++ b/I2C-Lab/AGENTS.md @@ -0,0 +1,99 @@ +# I2C-Lab AGENTS.md — 工程约束与开发进度 + +> 面向 Agent/开发者的工程档案。使用说明见 `README.md`; 仓库整体进度见根 +> `agent.md`。遵循全仓约定: 三区目录(src 人写 / assets 可再生 / release 交付)、 +> Python 3.14 + PySide6-Fluent-Widgets、纯标准库协议模块、PyInstaller onefile。 + +## 1. 项目约定 + +- **定位**: I2C 总线监控调试台(2026-10-02 启动, 原 §3.5 三合一需求)。 + 第一阶段 = 纯被动监控; 主机/从机为后续阶段(见 README §7)。 +- **与 I2C-Inject 关系**: I2C-Inject 保持"暂停开发, 留档备查"状态不复活; + 本工具是新开的独立目录, 吸收其资产(接线规矩 GP8/9、`=`/`!` 回显约定、 + 解码算法、图标母题), 主机阶段落地后取代其"注入"职能。 +- **解码语义权威是 `src/i2cdec.py`**(算法源自 I2C-Inject `i2c_sniff_decode.py` + 真机验证版本): 固件 `main.c` 的解码状态机与它的 `decode_edges()` 必须语义 + 一致; T/W 行语法以 `to_tline()/parse_tline()/parse_wline()` 为准, 改任一侧 + 必须同步另一侧并补 selftest 向量。 +- **时间戳约定**: 单位 us(int/u32); t0=START 沿, t1=收尾沿(STOP/rep-START/ + 最后边沿); 边沿 pins 编码 bit0=SDA bit1=SCL(GP8/GP9)。 +- **固件架构**: 单 PIO SM `bus_watch`(变沿即压 FIFO, `push noblock`) + + 核1 取时间戳并跑解码状态机(与 GUI 解码同语义) + 核0 USB CDC 命令行。 + 250MHz/vreg 1.15V。修改引脚/波特/协议时注意 `README.md` §5 与 GUI 同步。 +- **pyserial 是唯一第三方运行依赖**(缺失降级提示); GUI/解码全标准库+PySide6。 +- **release/ 交付物不入库**(2026-10-13 起, 与 DLPilot/SerialMate 的 exe 入库约定不同): + exe/UF2/BIN/zip 留本地, 待真机验收正式发布时再走流水线(`.gitignore` 已挡 + `I2C-Lab/release/`)。 + +## 2. 构建 + +### PC 端 + +```powershell +cd src +python selftest.py ; python selftest_gui.py # 改 i2cdec/main 后必跑 +python main.py --seconds 5 # GUI 冒烟 +python main.py --seconds 5 --demo # 演示模式冒烟(无硬件) +python build_exe.py ; python package_release.py 0.1.0 +python publish_gitea.py ../release/I2C-Lab-v0.1.0-win64.zip --insecure +``` + +### 固件(见 README §6) + +pico-sdk 2.3.1 + tinyusb(锁定提交) + Arm GNU 13.3 + CMake/Ninja; +UF2 用 `make_uf2.py`(**不要**加 `pico_add_extra_outputs`, 会拉 picotool)。 + +## 3. 开发进度 + +### v0.1.0(2026-10-02, 未发布——待真机验收) + +- 固件: bus_watch.pio(8 指令边沿捕获) + main.c 双核(核1 解码+事务环+边沿环, + 核0 CLI + T/W 流), 命令 ?/v/mon/stats/wave/boot; 长事务 56 字节截断('L'); + 疑似丢沿统计(双位同变启发); wave 快照期间边沿环暂停(有缺口, 已注释)。 +- PC 端: i2cdec.py(解码/T行/W行/CSV/编码器) 73 项自测全绿(含 100k/400k + 往返、随机鲁棒、截断标志); probelink.py(行协议链路线程); waveview.py + (QPainter 波形); main.py(Fluent 双栏 GUI, 演示模式); theme/config 同源适配; + selftest_gui.py 29 项全绿; build/package/publish 三件套适配; exe 已打包。 +- UF2 生成链: objcopy + make_uf2.py(往返自检 + 与真机 MicroPython UF2 + 逐字段核对通过); 产物保留本地 release/firmware/(交付物不入库, 见 §1 项目约定)。 + +### 待真机验收清单(下次接板) + +- [ ] BPI-Pico 刷 UF2, USB CDC 枚举, `v`/`stats` 回显 +- [ ] 挂 demo 板(LT8619C)实听: T 行滚动, 与已知轮询周期(~500ms)对照 +- [ ] 0x606E 位交换注入用 SerialMate/旧工具对照, I2C-Lab 监控读数一致 +- [ ] wave 快照在 400kHz 下波形完整, 缩放/注记正常 +- [ ] 长时间(≥30min)挂机: trans_drop/susp 不增长 +- [ ] 验收后: 打 tag `I2C-Lab-v0.1.0` → package → publish(含 UF2) + +## 4. 已提出、尚未实现(路线) + +- 主机模式: 硬件 I2C 主机 + I2C-Inject 择机注入语义(静置窗口/重试/读回 + 校验), GUI 寄存器读写卡 + 总线扫描; 命令 wr/rd/scan。 +- 从机模拟: 硬件 I2C 从机 + PC 应答表(寄存器值由 GUI 配置/回调)。 +- 监控增强: 协议语义插件(LT8619C bank/寄存器注释)、触发条件抓取、 + 长记录落盘、SMBus(PEC)注记、10bit 地址识别(目前按 7bit 解)。 +- 波形增强: 按事务/地址着色、位级数值标注(需边沿级重解码——已具备)。 + +## 5. 踩坑表(勿重蹈) + +| # | 问题 | 教训 | +|---|---|---| +| 1 | 解码器位缓冲在第 8 位清零 | 应凑满 9 位(8 数据+1 应答)才清——首版把 ACK 位错并进下一字节(数据整体右移一位); selftest 往返向量当场抓住 | +| 2 | 事务 acks 数量约定 | acks = 地址应答 + 每数据字节一个(共 n+1); 读事务末字节应答是主机发的 NAK, 造测试向量时别多写 | +| 3 | pico-sdk 2.x 板名 | `rpipico` 已更名 `pico`, 用 `-DPICO_BOARD=pico` | +| 4 | SDK 2.x UF2 依赖 picotool | `pico_add_extra_outputs` 会 FetchContent 拉 github(本机不通); 去掉该行, 用 objcopy+make_uf2.py; UF2 块布局用真机 UF2 解剖核对(偏移 20=blockNo 24=numBlocks 28=familyID), 别按记忆写 | +| 5 | pioasm 需要宿主 C++ 编译器 | 本机无 VS/mingw; 解法: `pip install ziglang`, 在 firmware/build/pioasm 预置 CMakeCache.txt, `CMAKE_CXX_COMPILER:STRING=;c++;-target;x86_64-windows-gnu;-static`(分号列表, 直接设 CXX 环境变量 CMake 不认) | +| 6 | ghproxy/gitclone 代理稳定性 | ghproxy.net 的 git 协议可用但大仓库会超时(pico-sdk 成功, picotool 两次超时); gitee.com/mirrors/tinyusb 是稳的; 环境变量 CXX 多词值 CMake 报 "Could not find compiler" | +| 7 | CDC 流控假设 | T 行流只在 `mon start` 且 USB 已连接时输出; wave 快照期间边沿环有缺口(暂停写入换一致性), 展示层要容忍 | +| 8 | CMD %ERRORLEVEL% 陷阱 | `cmd1 && cmd2 & echo %ERRORLEVEL%` 的 %ERRORLEVEL% 在解析期展开(恒为旧值); 管道 `findstr` 的退出码也会顶掉 git 的 | +| 9 | PySide6 实例级枚举访问 | `palette().color(pal.Base)` 在 PySide6 6.11 抛 AttributeError(应为 `QPalette.ColorRole.Base` 类级访问); 首帧绘制时才炸, 表象是 `win.show()` 处 access violation(faulthandler 定位), offscreen 自测抓不到——**windows 平台 GUI 冒烟不可省**; 自定义 paintEvent 必须 try/finally `p.end()` | + +## 6. 镜像与外部资源 + +- pico-sdk: `D:\toolchain\pico-sdk`(2.3.1, 浅克隆), tinyusb 锁定 `86ad6e56` + (0.18.0, 来自 gitee.com/mirrors/tinyusb) +- Arm GNU 13.3 / CMake 3.31 / Ninja: `D:\toolchain\*`(PATH 已有 11.3, 13.3 需全路径) +- zig(宿主编译 pioasm 用): `pip install ziglang`, exe 在 + `D:\toolchain\zig\ziglang\zig.exe`(pip -t 平铺安装时注意真实路径在包目录下) +- 本仓代理前缀: `https://ghproxy.net/https://github.com/...`(git clone 可用) diff --git a/I2C-Lab/README.md b/I2C-Lab/README.md new file mode 100644 index 0000000..e059726 --- /dev/null +++ b/I2C-Lab/README.md @@ -0,0 +1,132 @@ +# I2C-Lab — I2C 总线监控调试台 + +> 更新:2026-10-02。由 **RP2040 probe 固件**(`firmware/`,pico-sdk C,刷入 +> BPI-Pico-RP2040)+ **PC 端 GUI**(`src/` / 打包版 `release/I2C-Lab.exe`)组成。 +> 在任意 I2C 总线上**纯被动实时监听**:事务流解码 + 逻辑分析仪式波形快照。 +> 前身 I2C-Inject(LT8619C 寄存器择机注入)保留在仓库备查;本工具是其 +> "完整 I2C 调试台"路线的第一阶段(监控),主机读写与从机模拟见 §7 路线。 + +--- + +## 1. 它能做什么 + +| 能力 | 说明 | +|---|---| +| **实时事务流** | START→STOP 完整解码: 7bit 地址/RW/每字节 ACK/NAK/数据/收尾方式(含 repeated-START 标记) | +| **地址过滤** | 只看关心的地址(十六进制空格分隔), 其余只计数不入表 | +| **波形快照** | 一次抓取最近 256~16384 个 SDA/SCL 边沿, 逻辑分析仪视图: 滚轮缩放(光标锚点)/拖动平移/双击适配/START-STOP 刻线/事务标签 | +| **CSV 导出** | 全量事务(≤2 万条滚动池)导出, Excel 直接打开 | +| **固件计数** | 边沿数/事务数/环形覆盖丢包/疑似丢沿, 判断总线活动与负载 | +| **演示模式** | 无硬件看界面: 模拟 LT8619C demo 板 ~500ms 轮询流量 | + +**被动性保证**: probe 两脚为纯输入(PIO 只读电平), 不加上拉、不驱动、 +不改总线时序——挂在运行中的 demo 板上零干扰(与 I2C-Inject 同一接线规矩)。 + +## 2. 硬件准备 + +| BPI-Pico-RP2040 | 目标总线 | +|---|---| +| GP8(物理脚 11) | SDA | +| GP9(物理脚 12) | SCL | +| GND | GND | + +- **不接 VCC、不外加上下拉**(总线由目标板自带上拉) +- 独立台架总线(无主)另说: 需 4.7kΩ 上拉×2 到 3.3V 才有静态高电平 +- 固件把系统时钟超到 250MHz(vreg 1.15V), 100/400kHz 总线余量充足; + 官方 USB 枚举不受影响 + +## 3. 刷固件(一次性) + +1. 按住 BOOTSEL 上电, 出现 `RPI-RP2` U 盘 +2. 把 `release/firmware/i2c_lab.uf2` 拖进去, 板子自动重启 +3. 设备管理器出现 `USB 串行设备`(VID 2E8A)即就绪; LED 上电快闪 3 次后, + **常亮 = 监控流开启** + +重新构建固件见 `firmware/` 源码与 §6。 + +## 4. GUI 使用 + +双击 `I2C-Lab.exe`(或 `python src/main.py`; `--demo` 进演示模式): + +1. **固件串口卡**: 选带 `[RP2040]` 标记的端口 → 连接(日志出现版本横幅) +2. **监控卡**: 「开始监控」→ 事务表开始滚动; 「暂停滚动」冻结表格方便读 +3. **地址过滤**: 如 `32 50`(十六进制); 空格分隔多个; 留空看全部 +4. **波形卡**: 设边沿数(默认 2048) → 「抓取波形」→ SCL/SDA 双线时序图; + 滚轮缩放, 拖动平移, 双击适配全宽; 绿/红虚线 = START/STOP +5. **导出CSV**: 保存当前全量事务池(不只表内可见行) +6. 主题: 跟随系统/浅色/深色(DLPilot/SerialMate 同款三件套) + +表格列: `# | t0(us) | 时长us | 地址 | RW | ACK | 数据 | 收尾`。 +地址 NAK 红色、截断/半字节断橙色、超 16 字节数据显示省略号(悬停看全量)。 + +## 5. 固件串口命令(可直接用串口终端调) + +命令行一行一回, `=` 正常 / `!` 告警(全仓约定): + +``` +? 帮助 +v 版本/时钟/角色 +mon start 开 T 行事务流(监控中) +mon stop 停流 +stats 计数: edges/trans/trans_drop/susp/ring_ovf/最近边沿距今 +wave [n] 倒出最近 n(默认1024, 上限16384)个边沿: W 行 + 'W.' 收尾 +boot 重进 BOOTSEL(免按按键) +``` + +T 行语法(与 PC 端 `i2cdec.py` 互为权威, 自测往返覆盖): + +``` +T + flags: '-' stop | 'r' repeated-START 收尾 | 'c' 流截断 | 'p' 半字节断 + 可追加 'L' = 长事务截断(>56 字节只保留前段) + acks : n+1 个 'A'/'N', 首个是地址应答 +W pins bit0=SDA bit1=SCL +``` + +## 6. 重新构建固件 + +依赖(本机已装齐, 新机器对照): + +- pico-sdk 2.3.1 + tinyusb 子模块(锁定提交), Arm GNU 13.x, CMake ≥3.16, Ninja +- pioasm 由 SDK 构建时自动编译; **宿主若无 C++ 编译器**(如本机), 预置 + `firmware/build/pioasm/CMakeCache.txt` 的 `CMAKE_CXX_COMPILER` 为分号列表 + 形式的 zig(`pip install ziglang` 后指向 ziglang/zig.exe, 见 AGENTS.md 踩坑表) +- SDK 2.x 的 UF2 依赖 picotool(要拉 github); 本仓改用 `make_uf2.py` + (ELF→objcopy→bin→UF2, 与真机 UF2 逐字段核对 + 往返自检) + +```powershell +cd firmware +cmake -B build -G Ninja -DPICO_SDK_PATH=D:/toolchain/pico-sdk ` + -DPICO_TOOLCHAIN_PATH=D:/toolchain/arm-gnu-toolchain-13.3.rel1 ` + -DPICO_BOARD=pico +cmake --build build + -O binary build\i2c_lab.elf build\i2c_lab.bin +python make_uf2.py build\i2c_lab.bin build\i2c_lab.uf2 +copy build\i2c_lab.uf2 ..\release\firmware\ +``` + +## 7. 路线(与 I2C-Inject 的关系) + +- [x] **v0.1 监控**(本版): 纯被动监听 + 事务流 + 波形快照 +- [ ] **主机模式**: probe 作为 I2C 主机发起读写/总线扫描(复活 I2C-Inject 的 + 择机注入语义——避让主控轮询、静置窗口注入、读回校验), GUI 加寄存器读写卡 +- [ ] **从机模拟**: probe 应答主机, 应答内容由 PC 端配置(RP2040 硬件 I2C + 从机 + 应答表), 用于顶替传感器/MCU 验证驱动 +- [ ] 监控增强: 字段级协议插件(如 LT8619C bank/寄存器语义注释)、触发抓取、 + 长时记录落盘 + +## 8. 故障排查 + +- 连接后无 T 行: `stats` 看 edges 是否增长; 不增长查接线/共地/上拉 +- 事务乱码: 确认 SDA/SCL 没接反(GP8=SDA!); `wave 512` 看原始边沿 +- `trans_drop`/`susp` 持续增长: 总线负载过高或毛刺多, 减小监控过滤范围 +- 波形抓取期间的事务流有缺口: 正常(`wave` 快照优先, 见固件注释) + +## 9. 目录内容 + +| 位置 | 用途 | +|---|---| +| `src/` | PC 端: main.py(GUI)/i2cdec.py(解码权威)/probelink.py(链路)/waveview.py(波形)/theme.py + 自检/打包/发布脚本 | +| `firmware/` | probe 固件: main.c + bus_watch.pio + CMakeLists + make_uf2.py + pico_sdk_import.cmake | +| `assets/` | 图标(沿用 I2C 橙色母题) | +| `release/` | I2C-Lab.exe(入库) + firmware/i2c_lab.uf2/.bin(入库); zip 不入库 | diff --git a/I2C-Lab/assets/i2c-lab.ico b/I2C-Lab/assets/i2c-lab.ico new file mode 100644 index 0000000..21d74b6 Binary files /dev/null and b/I2C-Lab/assets/i2c-lab.ico differ diff --git a/I2C-Lab/assets/i2c-lab.png b/I2C-Lab/assets/i2c-lab.png new file mode 100644 index 0000000..0c6fc47 Binary files /dev/null and b/I2C-Lab/assets/i2c-lab.png differ diff --git a/I2C-Lab/assets/i2c-lab.svg b/I2C-Lab/assets/i2c-lab.svg new file mode 100644 index 0000000..b8be3f0 --- /dev/null +++ b/I2C-Lab/assets/i2c-lab.svg @@ -0,0 +1,6 @@ + + + + I2C + Lab + diff --git a/I2C-Lab/firmware/CMakeLists.txt b/I2C-Lab/firmware/CMakeLists.txt new file mode 100644 index 0000000..009bf28 --- /dev/null +++ b/I2C-Lab/firmware/CMakeLists.txt @@ -0,0 +1,27 @@ +# I2C-Lab probe 固件(RP2040 / pico-sdk) +# 构建(见 ../README.md §固件): +# cmake -B build -G Ninja -DPICO_SDK_PATH= -DPICO_TOOLCHAIN_PATH= +# cmake --build build +# 注: 不用 pico_add_extra_outputs(它会 FetchContent 拉 github 上的 picotool, +# 本机网络不通)。UF2 由 make_uf2.py 从 .bin 生成(带往返自检)。 +cmake_minimum_required(VERSION 3.16) + +include(pico_sdk_import.cmake) # 由 pico-sdk/external/ 复制 + +project(i2c_lab_fw C CXX ASM) +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) + +pico_sdk_init() + +add_executable(i2c_lab main.c) +pico_generate_pio_header(i2c_lab ${CMAKE_CURRENT_LIST_DIR}/bus_watch.pio) +target_link_libraries(i2c_lab + pico_stdlib + pico_multicore + hardware_pio + hardware_clocks + hardware_vreg +) +pico_enable_stdio_usb(i2c_lab 1) +pico_enable_stdio_uart(i2c_lab 0) diff --git a/I2C-Lab/firmware/bus_watch.pio b/I2C-Lab/firmware/bus_watch.pio new file mode 100644 index 0000000..6d725d1 --- /dev/null +++ b/I2C-Lab/firmware/bus_watch.pio @@ -0,0 +1,31 @@ +; +; bus_watch.pio — I2C 总线被动边沿捕获(RP2040 PIO) +; ================================================== +; 思路同 pico-examples 的 logic_analyser: 硬件只做最难的"确定性捕获" +; (电平变化即压入 FIFO), 协议解码(I2C 状态机)留给 CPU——与 PC 端 +; i2cdec.py 共享同一套算法语义(源自 I2C-Inject 真机验证的 +; i2c_sniff_decode.py)。 +; +; 接线约定(沿用 I2C-Inject): GP8=SDA, GP9=SCL, 共地, 不加上拉, +; 纯输入不驱动(开漏总线零干扰)。 +; 引脚映射: in_base = GP8 → ISR/X/Y 的 bit0=SDA bit1=SCL。 +; +; 每个 FIFO 字 = 新电平值(0..3)。时间戳由 CPU 取(边沿间隔 ≥ 500ns, +; 核1 专职轮询, 抖动远小于 1us)。 +; 环形轮询 5 条指令 @250MHz = 20ns/圈, 可分辨 ~40ns 毛刺; +; push noblock: FIFO 满则丢当前沿(Y 已更新, 后续沿仍连续可解, +; CPU 侧以"双位同变"启发式统计疑似丢沿)。 + +.program bus_watch + +top: + mov isr, null ; ISR 清零(低 2 位待填) + in pins, 2 ; ISR[1:0] = SCL<<1 | SDA + mov x, isr + jmp x != y, changed ; 电平有变 -> 送出 + jmp top ; 无变 -> 继续盯 + +changed: + mov y, x ; 先更新基线(即使 push 被丢也不失步) + push noblock ; 送出新电平(FIFO 满则丢弃) + jmp top diff --git a/I2C-Lab/firmware/main.c b/I2C-Lab/firmware/main.c new file mode 100644 index 0000000..5852d61 --- /dev/null +++ b/I2C-Lab/firmware/main.c @@ -0,0 +1,388 @@ +/** + * I2C-Lab probe — RP2040 I2C 总线被动监控固件 v0.1.0 + * ================================================================== + * 硬件: BPI-Pico-RP2040(与 Raspberry Pi Pico 引脚兼容) + * 接线: GP8=SDA, GP9=SCL, GND 共地; 纯被动(不加上拉/不驱动)。 + * + * 架构(双核分工): + * core1 硬实时: 轮询 PIO bus_watch SM 的 RX FIFO, 取 µs 时间戳, + * 跑 I2C 解码状态机(语义与 PC 端 i2cdec.py 一致) → + * 事务环形缓冲(核0 消费) + 边沿环形缓冲(波形快照用); + * core0 USB CDC 文本命令行(全仓 `=`/`!` 回显约定), T 行事务流, + * W 行边沿快照, LED 指示。 + * + * 监控行协议(与 i2cdec.py 的 to_tline/parse_tline 对齐): + * T + * flags: '-' stop | 'r' repeated-START 收尾 | 'c' 流截断 + * | 'p' 半字节断, 可追加 'L' = 长事务截断(>56 字节) + * acks : n+1 个 'A'/'N'(首个为地址应答) + * W 边沿快照行(pins bit0=SDA bit1=SCL), 'W.' 结束 + * '=' 开头为命令应答, '!' 开头为告警 + * + * 命令: ? | v | mon start|stop | stats | wave [n] | mon led ... + * 时钟: 250MHz(overclock, vreg 1.15V), USB 独立 PLL 不受影响。 + */ + +#include +#include +#include +#include + +#include "pico/stdlib.h" +#include "pico/multicore.h" +#include "pico/bootrom.h" +#include "pico/stdio_usb.h" +#include "hardware/pio.h" +#include "hardware/clocks.h" +#include "hardware/vreg.h" +#include "bus_watch.pio.h" + +#define FW_VERSION "0.1.0" +#define PIN_SDA 8 +#define PIN_SCL 9 +#define PIN_LED 25 +#define PIO_BUS pio0 + +/* 长事务只保留前 DATA_CAP 个数据字节(n 总数照计, flags 加 'L') */ +#define DATA_CAP 56 +#define TRANS_RING_SIZE 256u /* 事务环形缓冲条数(2 的幂) */ +#define EDGE_RING_SIZE (16u * 1024u) /* 边沿环形缓冲条数(2 的幂), 64KB */ +#define EDGE_DT_MAX 0xFFFFFEu /* Δt 压缩上限(>16.7s 的间隙按此记) */ + +enum { END_STOP = 0, END_RSTART, END_CUT, END_PARTIAL }; + +typedef struct { + uint32_t t0; + uint32_t dur; + uint8_t addr7; + uint8_t rw; /* 0=W 1=R */ + uint8_t ended; /* END_* */ + uint8_t truncated; /* 长事务截断 */ + uint8_t n; /* 记录的数据字节数 ≤ DATA_CAP */ + uint8_t ackn; /* 记录的应答数 = n+1 */ + uint8_t data[DATA_CAP]; + uint64_t acks; /* bit i(i 0) ? d.bytes[0] : 0; + r.addr7 = addr_byte >> 1; + r.rw = addr_byte & 1; + r.ended = (uint8_t)ended; + int ndata_total = (d.ntotal > 0) ? d.ntotal - 1 : 0; + r.truncated = ndata_total > DATA_CAP; + int n_listed = d.nbytes_stored - 1; + if (n_listed > DATA_CAP) n_listed = DATA_CAP; + if (n_listed < 0) n_listed = 0; + r.n = (uint8_t)n_listed; + int ackn_listed = d.ackn < (n_listed + 1) ? d.ackn : n_listed + 1; + r.ackn = (uint8_t)ackn_listed; + memcpy(r.data, d.bytes + 1, (size_t)n_listed); + r.acks = d.acks; /* 高位未置位的应答位视为 N/A, 打印按 ackn */ + + uint32_t h = trans_head; + if (h - trans_tail_pub >= TRANS_RING_SIZE) { /* 覆盖最旧 */ + stats.trans_drop++; + trans_tail_pub = h - TRANS_RING_SIZE + 1; + } + trans_ring[h & (TRANS_RING_SIZE - 1)] = r; + trans_head = h + 1; + stats.trans++; + + d.in_txn = 0; +} + +static void open_txn(uint32_t t) { + d.in_txn = 1; + d.t0 = t; + d.nbits = d.shift = 0; + d.nbytes_stored = 0; + d.ntotal = 0; + d.acks = 0; + d.ackn = 0; +} + +static void store_byte(uint8_t v) { + d.ntotal++; + if (d.nbytes_stored < (int)(sizeof d.bytes)) d.bytes[d.nbytes_stored++] = v; +} + +/* 一个 FIFO 字(新电平)的处理: 解码状态机 + 边沿入环 */ +static void feed_word(uint32_t pins, uint32_t t) { + uint32_t sda = pins & 1, scl = (pins >> 1) & 1; + if (have_prev) { + if (scl && prev_scl) { /* SCL 高电平期 SDA 变化 */ + if (prev_sda && !sda) { /* START / repeated START */ + push_trans(t, END_RSTART); + open_txn(t); + } else if (!prev_sda && sda) { /* STOP */ + push_trans(t, END_STOP); + } + } else if (scl && !prev_scl) { /* SCL 上升沿采样 */ + if (d.in_txn) { + if (d.nbits < 8) { + d.shift = (uint8_t)((d.shift << 1) | sda); + if (++d.nbits == 8) store_byte(d.shift); + } else { /* 第 9 位 = 应答 */ + if (d.ackn < 64 && !sda) + d.acks |= 1ull << d.ackn; + d.ackn++; + d.nbits = 0; + d.shift = 0; + } + } + } + if ((sda != prev_sda) && (scl != prev_scl)) + stats.susp++; /* 合法 I2C 不应双位同变 */ + } + prev_sda = sda; + prev_scl = scl; + have_prev = 1; + stats.edges++; + stats.last_edge_t = t; + + if (!edge_hold) { /* 边沿环(波形快照源) */ + uint32_t dt = t - ring_last_t; + if (dt > EDGE_DT_MAX) dt = EDGE_DT_MAX; + uint32_t h = edge_head; + if (h && (h & (EDGE_RING_SIZE - 1)) == 0) + stats.ring_ovf++; /* 每绕环覆盖一圈记一次 */ + edge_ring[h & (EDGE_RING_SIZE - 1)] = (dt << 2) | pins; + edge_head = h + 1; + edge_newest_t = t; + } + ring_last_t = t; +} + +static void core1_entry(void) { + uint sm = bus_sm; /* 由 core0 声明并初始化 */ + while (true) { + while (!pio_sm_is_rx_fifo_empty(PIO_BUS, sm)) { + uint32_t w = pio_sm_get(PIO_BUS, sm) & 3; + feed_word(w, time_us_32()); + } + tight_loop_contents(); + } +} + +/* ---------------- core0: 命令行 + 事务流 ---------------- */ + +static volatile bool mon_active = false; + +static void cmd_help(void) { + printf("= 命令: ? 帮助 | v 版本 | mon start|stop 监控流开关" + " | stats 计数 | wave [n] 最近 n 个边沿快照(默认1024, 上限%u)" + " | boot 重进 BOOTSEL\n", EDGE_RING_SIZE); +} + +static void cmd_stats(void) { + uint32_t now = time_us_32(); + uint32_t ago = now - stats.last_edge_t; + printf("= stats edges=%u trans=%u trans_drop=%u susp=%u ring_ovf=%u " + "last_edge_ago_us=%u mon=%d\n", + stats.edges, stats.trans, stats.trans_drop, stats.susp, + stats.ring_ovf, ago, mon_active ? 1 : 0); +} + +static void cmd_wave(uint32_t n) { + if (n == 0) n = 1024; + if (n > EDGE_RING_SIZE) n = EDGE_RING_SIZE; + edge_hold = 1; + sleep_ms(2); /* 等核1 让出环写 */ + + uint32_t h = edge_head; + uint32_t stored = h < EDGE_RING_SIZE ? h : EDGE_RING_SIZE; + uint32_t m = n < stored ? n : stored; + if (m == 0) { + printf("! wave 无边沿可倒出\n"); + edge_hold = 0; + return; + } + /* 边沿环存 Δt: 先求区间内 Δ 和, 反推最旧一条的绝对时间 */ + uint32_t sum_dt = 0; + for (uint32_t i = h - m + 1; i < h; i++) + sum_dt += edge_ring[i & (EDGE_RING_SIZE - 1)] >> 2; + uint32_t t = edge_newest_t - sum_dt; + printf("= wave %u\n", m); + for (uint32_t i = h - m; i < h; i++) { + uint32_t e = edge_ring[i & (EDGE_RING_SIZE - 1)]; + printf("W %u %u\n", t, e & 3); + t += e >> 2; + } + printf("W.\n"); + edge_hold = 0; /* 快照期间新边沿有缺口, stats.susp 可见 */ +} + +static void emit_trans(volatile trans_rec_t *r) { + char flags[4] = "-rcp"; + char f[3] = {flags[r->ended], 0, 0}; + if (r->truncated) { f[1] = 'L'; f[2] = 0; } + char acks[DATA_CAP + 2]; + for (int i = 0; i < r->ackn; i++) + acks[i] = (r->acks & (1ull << i)) ? 'A' : 'N'; + acks[r->ackn] = 0; + printf("T %u %u %02X %c %s %s %u", r->t0, r->dur, r->addr7, + r->rw ? 'R' : 'W', f, acks, r->n); + for (int i = 0; i < r->n; i++) + printf(" %02X", r->data[i]); + printf("\n"); +} + +/* 消费事务环: 监控开则打印, 关则丢弃计数 */ +static void drain_trans(void) { + static uint32_t tail = 0; /* 核0 本地 */ + uint32_t budget = 128; /* 每轮上限, 保证命令行响应 */ + while (budget--) { + if (tail < trans_tail_pub) tail = trans_tail_pub; + if (tail >= trans_head) break; + volatile trans_rec_t *r = &trans_ring[tail & (TRANS_RING_SIZE - 1)]; + if (mon_active && stdio_usb_connected()) + emit_trans(r); + tail++; + } +} + +static void handle_line(char *line) { + /* 就地去 CR/LF 与空白 */ + size_t len = strlen(line); + while (len && (line[len - 1] == '\r' || line[len - 1] == '\n' + || isspace((unsigned char)line[len - 1]))) + line[--len] = 0; + char *save = NULL; + char *cmd = strtok_r(line, " \t", &save); + if (!cmd) return; + + if (!strcmp(cmd, "?") || !strcmp(cmd, "help")) { + cmd_help(); + } else if (!strcmp(cmd, "v")) { + printf("= i2c-lab probe v%s GP8=SDA GP9=SCL sys=%uMHz " + "(被动监控, T/W 行协议)\n", FW_VERSION, + (unsigned)(clock_get_hz(clk_sys) / 1000000)); + } else if (!strcmp(cmd, "mon")) { + char *sub = strtok_r(NULL, " \t", &save); + if (sub && !strcmp(sub, "start")) { + mon_active = true; + printf("= mon start (T 行流开启)\n"); + } else if (sub && !strcmp(sub, "stop")) { + mon_active = false; + printf("= mon stop\n"); + } else { + printf("! mon start|stop\n"); + } + } else if (!strcmp(cmd, "stats")) { + cmd_stats(); + } else if (!strcmp(cmd, "wave")) { + char *arg = strtok_r(NULL, " \t", &save); + cmd_wave(arg ? (uint32_t)strtoul(arg, NULL, 0) : 1024); + } else if (!strcmp(cmd, "boot")) { + printf("= 重进 BOOTSEL\n"); + sleep_ms(50); + reset_usb_boot(0, 0); + } else { + printf("! 未知命令 '%s', '?' 看帮助\n", cmd); + } +} + +int main(void) { + vreg_set_voltage(VREG_VOLTAGE_1_15); + set_sys_clock_khz(250000, false); /* 超频提高捕获余量; 失败回落默认 */ + + stdio_init_all(); + + /* PIO: 总线被动监视(纯输入, 关上下拉) */ + uint off = pio_add_program(PIO_BUS, &bus_watch_program); + bus_sm = pio_claim_unused_sm(PIO_BUS, true); + pio_sm_config c = bus_watch_program_get_default_config(off); + sm_config_set_in_pins(&c, PIN_SDA); /* in_base=8 → bit0=SDA bit1=SCL */ + sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_RX); + pio_sm_init(PIO_BUS, bus_sm, off, &c); + pio_gpio_init(PIO_BUS, PIN_SDA); + pio_gpio_init(PIO_BUS, PIN_SCL); + gpio_disable_pulls(PIN_SDA); + gpio_disable_pulls(PIN_SCL); + /* 纯输入: 不设 pindir, 程序也从不驱动 — 对开漏总线零干扰 */ + + gpio_init(PIN_LED); + gpio_set_dir(PIN_LED, GPIO_OUT); + + multicore_launch_core1(core1_entry); + + for (int i = 0; i < 3; i++) { /* 上电自检快闪 3 次(同 I2C-Inject) */ + gpio_put(PIN_LED, 1); sleep_ms(80); + gpio_put(PIN_LED, 0); sleep_ms(80); + } + printf("= i2c-lab probe v%s ready GP8=SDA GP9=SCL ('?' 帮助)\n", + FW_VERSION); + + char buf[128]; + size_t bi = 0; + bool bannered = false; + while (true) { + /* 命令行: 非阻塞收字符组行 */ + int ch = getchar_timeout_us(0); + if (ch != PICO_ERROR_TIMEOUT) { + if (ch == '\r' || ch == '\n') { + if (bi < sizeof buf) buf[bi] = 0; + if (bi) handle_line(buf); + bi = 0; + } else if (bi < sizeof buf - 1) { + buf[bi++] = (char)ch; + } + } + /* USB 枚举完成后补发横幅(插入即见) */ + if (!bannered && stdio_usb_connected()) { + bannered = true; + printf("= i2c-lab probe v%s connected GP8=SDA GP9=SCL\n", + FW_VERSION); + } + drain_trans(); + gpio_put(PIN_LED, mon_active); /* 常亮 = 监控中(同注入器约定) */ + sleep_ms(1); + } +} diff --git a/I2C-Lab/firmware/make_uf2.py b/I2C-Lab/firmware/make_uf2.py new file mode 100644 index 0000000..a9661fc --- /dev/null +++ b/I2C-Lab/firmware/make_uf2.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +make_uf2.py — 最小 UF2 封装器(RP2040) +================================================== +背景: pico-sdk 2.x 生成 UF2 依赖 picotool(FetchContent 拉 github), 本机 +网络对 github 不通时走本脚本替代: elf 先经 arm-none-eabi-objcopy 转 bin, +再按 UF2 规范(Microsoft UF2 spec, family 变体)打包。 + +用法: + python make_uf2.py [base_addr_hex] + python make_uf2.py --test # 内部往返自检(解包回来比对 payload) + +UF2 块格式(512B/块): magic0 0x0A324655, magic1 0x9E5D5157, +flags 0x00002000(family), addr, len(≤476, 本工具固定 256), size, blockNo, +numBlocks, familyId=0xE48BFF56(RP2040), data[476], magicEnd 0x0AB16F30。 +""" +import struct +import sys + +MAGIC0 = 0x0A324655 +MAGIC1 = 0x9E5D5157 +MAGIC_END = 0x0AB16F30 +FLAG_FAMILY = 0x00002000 +FAMILY_RP2040 = 0xE48BFF56 +RP2040_FLASH_BASE = 0x10000000 +PAYLOAD = 256 # RP2040 flash 页大小, 256 对齐最稳 + + +def bin_to_uf2(data: bytes, base: int = RP2040_FLASH_BASE, + family: int = FAMILY_RP2040) -> bytes: + """块布局按官方 uf2conv 实现(经真机 UF2 逐块核对): + 0:magic0 4:magic1 8:flags 12:targetAddr 16:payloadSize + 20:blockNo 24:numBlocks 28:familyID 32:data[476] 508:magicEnd""" + if len(data) % PAYLOAD: # 补齐到整页 + data = data + b'\x00' * (PAYLOAD - len(data) % PAYLOAD) + total = len(data) // PAYLOAD + out = bytearray() + for i in range(total): + chunk = data[i * PAYLOAD:(i + 1) * PAYLOAD] + block = struct.pack( + ' 476: + raise ValueError(f'块 {n} payloadSize 超限') + if base is None: + base, total = addr, nt + if no != n or nt != total: + raise ValueError(f'块 {n} 序号/总数错') + chunks[no] = (addr, b[32:32 + nb]) + payload = bytearray() + expect = base + for i in range(total): + if i not in chunks: + raise ValueError(f'缺块 {i}') + addr, c = chunks[i] + if addr != expect: + raise ValueError(f'块 {i} 地址不连续: {addr:#x} != {expect:#x}') + payload += c + expect += len(c) + return base, bytes(payload) + + +def _selftest(): + import random + rng = random.Random(7) + for size in (0, 1, 255, 256, 257, 4096, 4097, 65536): + blob = bytes(rng.randrange(256) for _ in range(size)) + if size == 0: + continue # 空镜像无意义(0 块) + uf2 = bin_to_uf2(blob, base=0x10000000) + base, back = uf2_to_payload(uf2) + pad = (-size) % PAYLOAD + assert base == 0x10000000 + assert back[:size] == blob and len(back) == size + pad, \ + f'size={size} 往返不一致' + print(f'PASS make_uf2 往返自检 ({(size + (-size) % PAYLOAD) // PAYLOAD} 块)') + return 0 + + +def main(argv): + if len(argv) >= 2 and argv[1] == '--test': + return _selftest() + if len(argv) < 3: + print(__doc__) + return 2 + with open(argv[1], 'rb') as f: + data = f.read() + base = int(argv[3], 16) if len(argv) > 3 else RP2040_FLASH_BASE + uf2 = bin_to_uf2(data, base=base) + with open(argv[2], 'wb') as f: + f.write(uf2) + print(f'UF2: {len(uf2)} B, {len(uf2) // 512} 块, ' + f'base=0x{base:08X}, payload={len(data)} B -> {argv[2]}') + # 写完即自检 + with open(argv[2], 'rb') as f: + b, p = uf2_to_payload(f.read()) + assert b == base and p[:len(data)] == data + print('PASS 写出文件解包往返一致') + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/I2C-Lab/firmware/pico_sdk_import.cmake b/I2C-Lab/firmware/pico_sdk_import.cmake new file mode 100644 index 0000000..d493cc2 --- /dev/null +++ b/I2C-Lab/firmware/pico_sdk_import.cmake @@ -0,0 +1,121 @@ +# This is a copy of /external/pico_sdk_import.cmake + +# This can be dropped into an external project to help locate this SDK +# It should be include()ed prior to project() + +# Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd. +# +# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +# following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +# disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH)) + set(PICO_SDK_PATH $ENV{PICO_SDK_PATH}) + message("Using PICO_SDK_PATH from environment ('${PICO_SDK_PATH}')") +endif () + +if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT)) + set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT}) + message("Using PICO_SDK_FETCH_FROM_GIT from environment ('${PICO_SDK_FETCH_FROM_GIT}')") +endif () + +if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH)) + set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH}) + message("Using PICO_SDK_FETCH_FROM_GIT_PATH from environment ('${PICO_SDK_FETCH_FROM_GIT_PATH}')") +endif () + +if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_TAG} AND (NOT PICO_SDK_FETCH_FROM_GIT_TAG)) + set(PICO_SDK_FETCH_FROM_GIT_TAG $ENV{PICO_SDK_FETCH_FROM_GIT_TAG}) + message("Using PICO_SDK_FETCH_FROM_GIT_TAG from environment ('${PICO_SDK_FETCH_FROM_GIT_TAG}')") +endif () + +if (PICO_SDK_FETCH_FROM_GIT AND NOT PICO_SDK_FETCH_FROM_GIT_TAG) + set(PICO_SDK_FETCH_FROM_GIT_TAG "master") + message("Using master as default value for PICO_SDK_FETCH_FROM_GIT_TAG") +endif() + +set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK") +set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch copy of SDK from git if not otherwise locatable") +set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "location to download SDK") +set(PICO_SDK_FETCH_FROM_GIT_TAG "${PICO_SDK_FETCH_FROM_GIT_TAG}" CACHE FILEPATH "release tag for SDK") + +if (NOT PICO_SDK_PATH) + if (PICO_SDK_FETCH_FROM_GIT) + include(FetchContent) + set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR}) + if (PICO_SDK_FETCH_FROM_GIT_PATH) + get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH BASE_DIR "${CMAKE_SOURCE_DIR}") + endif () + FetchContent_Declare( + pico_sdk + GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk + GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG} + ) + + if (NOT pico_sdk) + message("Downloading Raspberry Pi Pico SDK") + # GIT_SUBMODULES_RECURSE was added in 3.17 + if (${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.17.0") + FetchContent_Populate( + pico_sdk + QUIET + GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk + GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG} + GIT_SUBMODULES_RECURSE FALSE + + SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src + BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build + SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild + ) + else () + FetchContent_Populate( + pico_sdk + QUIET + GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk + GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG} + + SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src + BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build + SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild + ) + endif () + + set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR}) + endif () + set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE}) + else () + message(FATAL_ERROR + "SDK location was not specified. Please set PICO_SDK_PATH or set PICO_SDK_FETCH_FROM_GIT to on to fetch from git." + ) + endif () +endif () + +get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}") +if (NOT EXISTS ${PICO_SDK_PATH}) + message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found") +endif () + +set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake) +if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE}) + message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' does not appear to contain the Raspberry Pi Pico SDK") +endif () + +set(PICO_SDK_PATH ${PICO_SDK_PATH} CACHE PATH "Path to the Raspberry Pi Pico SDK" FORCE) + +include(${PICO_SDK_INIT_CMAKE_FILE}) diff --git a/I2C-Lab/src/build_exe.py b/I2C-Lab/src/build_exe.py new file mode 100644 index 0000000..e638b70 --- /dev/null +++ b/I2C-Lab/src/build_exe.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +把 src/main.py 打包成单文件 exe(工具名: I2C-Lab) +================================================ +与 SerialMate/src/build_exe.py 同一套路: onefile + --noconsole, Python 解释器、 +PySide6 和 PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。 + +用法: + python build_exe.py 自动安装 PyInstaller(如缺)并打包 + python build_exe.py --debug 额外打一个带控制台的变体 + +产物: release/I2C-Lab.exe +spec 由本脚本生成到 src/build/(不入库), 调整打包逻辑直接改这里的模板。 +""" +import os +import subprocess +import sys + +# 本脚本位于 src/, 打包产物输出到工具根的 release/ +SRC = os.path.dirname(os.path.abspath(__file__)) +BASE = os.path.dirname(SRC) +RELEASE = os.path.join(BASE, 'release') +WORK = os.path.join(SRC, 'build') +ICO = os.path.join(BASE, 'assets', 'i2c-lab.ico') + +SPEC_TEMPLATE = """\ +# -*- coding: utf-8 -*- +# 由 build_exe.py 生成, 不要手改; 调整打包逻辑请改 src/build_exe.py。 +from PyInstaller.utils.hooks import collect_all + +qfluent_datas, qfluent_binaries, qfluent_hidden = collect_all('qfluentwidgets') +a = Analysis( + [r'{entry}'], pathex=[r'{src}'], binaries=qfluent_binaries, + datas=qfluent_datas + {icon_datas}, hiddenimports=qfluent_hidden, + noarchive=False) +# Qt 6.11 使用 Windows 非版本化 ICU 导出; PATH 上 Poppler/Conda 的版本化 +# ICU(icuuc.dll/icudt78.dll)会被 PyInstaller 误收, 令 QtCore.pyd 启动报 +# WinError 127 —— 打包时剔除, 运行时用系统自带 ICU。 +a.binaries = [b for b in a.binaries if b[0].lower() not in ('icuuc.dll', 'icudt78.dll')] +pyz = PYZ(a.pure) +exe = EXE(pyz, a.scripts, a.binaries, a.datas, [], name='{name}', + debug=False, strip=False, upx=False, console={console}{icon_clause}) +""" + + +def ensure_pyinstaller(): + try: + import PyInstaller # noqa: F401 + return + except ImportError: + pass + print('PyInstaller 未安装, 正在安装...') + subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller']) + + +def remove_stale(exe_path): + """删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试""" + if not os.path.exists(exe_path): + return + try: + os.remove(exe_path) + return + except PermissionError: + pass + import time + print(f'{exe_path} 被占用, 结束残留进程后重试...') + subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)], + capture_output=True) + time.sleep(1.0) + os.remove(exe_path) + + +def build(name, console): + remove_stale(os.path.join(RELEASE, name + '.exe')) + if os.path.exists(ICO): + icon_datas = f"[(r'{ICO}', 'assets')]" + icon_clause = f", icon=r'{ICO}'" + else: + print('警告: 未找到 assets/i2c-lab.ico, 本次打包不带图标') + icon_datas, icon_clause = '[]', '' + spec = SPEC_TEMPLATE.format(entry=os.path.join(SRC, 'main.py'), src=SRC, + icon_datas=icon_datas, name=name, + console=console, icon_clause=icon_clause) + os.makedirs(WORK, exist_ok=True) + spec_path = os.path.join(WORK, name + '.spec') + with open(spec_path, 'w', encoding='utf-8') as f: + f.write(spec) + cmd = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean', + '--distpath', RELEASE, '--workpath', WORK, spec_path] + print('\n>>', ' '.join(cmd)) + subprocess.check_call(cmd) + out = os.path.join(RELEASE, name + '.exe') + print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)') + + +def main(): + build_console = '--debug' in sys.argv + ensure_pyinstaller() + # 常规版: 双击即用, 无黑框 + build('I2C-Lab', 'False') + if build_console: + # 调试版: 带控制台, 异常栈可见 + build('I2C-Lab-console', 'True') + + +if __name__ == '__main__': + main() diff --git a/I2C-Lab/src/config.py b/I2C-Lab/src/config.py new file mode 100644 index 0000000..0f2eee7 --- /dev/null +++ b/I2C-Lab/src/config.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +运行目录定位与设置持久化(BASE_DIR 语义与 SerialMate/DLPilot config.py 一致) +========================================================================== +frozen(PyInstaller onefile)时 BASE_DIR=exe 所在目录, 源码运行时=本文件目录。 +设置写/读自 BASE_DIR/i2c_lab.json, 不建子目录。 +读容错: 缺失/损坏/缺键一律回落默认值, GUI 无需判空。 +""" + +import json +import os +import sys + +if getattr(sys, 'frozen', False): + BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) +else: + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +CONFIG_FILE = os.path.join(BASE_DIR, 'i2c_lab.json') + +DEFAULTS = { + 'serial': {'port': ''}, + 'mon': {'addr_filter': '', 'paused': False, 'wave_n': 2048}, +} + + +def _merge(base, over): + """递归覆盖: over 缺键用 base 补齐, 多余键保留(向前兼容)""" + out = dict(base) + for k, v in over.items(): + if k in base and isinstance(base[k], dict) and isinstance(v, dict): + out[k] = _merge(base[k], v) + else: + out[k] = v + return out + + +def load(): + """读设置; 任何异常回落默认(键全齐), 不抛出""" + try: + with open(CONFIG_FILE, encoding='utf-8') as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError('配置根不是对象') + except Exception: + return json.loads(json.dumps(DEFAULTS)) # 深拷贝默认值 + return _merge(DEFAULTS, data) + + +def save(cfg): + """写设置; 失败静默(只读目录等场景不阻塞退出)""" + try: + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(cfg, f, ensure_ascii=False, indent=1) + except Exception: + pass diff --git a/I2C-Lab/src/i2cdec.py b/I2C-Lab/src/i2cdec.py new file mode 100644 index 0000000..2aabdbe --- /dev/null +++ b/I2C-Lab/src/i2cdec.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +i2cdec.py — I2C 领域模型/解码(纯标准库) +================================================== +本模块是 I2C-Lab 三方共用的"协议语法"权威: + - 固件监控流 T 行(事务)与 W 行(边沿快照)的解析(与 firmware/main.c 语法一致); + - 边沿序列 -> 事务解码 decode_edges()(算法自 I2C-Inject i2c_sniff_decode.py, + 在真机总线上验证过, 引脚编码沿用: val bit0=SDA bit1=SCL); + - 事务 -> 边沿序列的参考编码器 encode_edges()(自测向量生成, 也规定了对齐时序); + - 事务显示行/CSV 导出。 + +时间单位一律 us(int)。地址为 7bit。 +""" + +# --------------------------------------------------------------------------- +# 领域模型 +# --------------------------------------------------------------------------- + +class Transaction: + """一次 I2C 事务: START .. (STOP | repeated START | 流被截断) + + 字段: + t0_us / t1_us START 边沿时刻 / 收尾边沿时刻(STOP/rep-START/最后边沿) + addr7 7bit 地址 + read True=读(R), False=写(W) + acks 每字节一个应答, True=ACK(SDA=0), [0] 是地址字节应答 + data 地址字节之后的数据字节(bytes) + ended 收尾方式: 'stop' / 'rstart' / 'cut'(流截断) / 'partial'(字节中途断) + truncated 固件长事务截断(data/acks 只含前段, 尾部丢弃) + """ + + __slots__ = ('t0_us', 't1_us', 'addr7', 'read', 'acks', 'data', 'ended', + 'truncated') + + def __init__(self, t0_us=0, addr7=0, read=False, acks=(), data=b'', + ended='stop', t1_us=None, truncated=False): + self.t0_us = int(t0_us) + self.t1_us = int(t0_us if t1_us is None else t1_us) + self.addr7 = int(addr7) + self.read = bool(read) + self.acks = tuple(bool(a) for a in acks) + self.data = bytes(data) + self.ended = ended + self.truncated = bool(truncated) + + # --- 便捷属性 --- + + @property + def addr_ack(self): + return self.acks[0] if self.acks else None + + @property + def data_acks(self): + return self.acks[1:] + + @property + def complete(self): + return self.ended == 'stop' + + @property + def dur_us(self): + return self.t1_us - self.t0_us + + def key(self): + """解码自测用等值比较(不含时间戳的不变内容)""" + return (self.addr7, self.read, self.acks, self.data, self.ended) + + # --- 显示 --- + + def addr_hex(self): + return f'0x{self.addr7:02X}' + + def rw_char(self): + return 'R' if self.read else 'W' + + def ack_str(self): + return ''.join('A' if a else 'N' for a in self.acks) or '-' + + def flag_str(self): + return {'stop': '', 'rstart': '+', 'cut': ' <未收尾>', + 'partial': ' <半字节断>'}.get(self.ended, '') + + def data_hex(self, limit=0): + """数据 hex; limit>0 时截断并加省略号""" + h = ' '.join(f'{b:02X}' for b in self.data) + if limit and len(self.data) > limit: + h = ' '.join(f'{b:02X}' for b in self.data[:limit]) + ' …' + return h + + def format(self, idx=None, with_dur=True): + """单行显示: [idx] t=us us 0x32 W: FF 60 ACK:AA""" + head = f'[{idx:3d}] ' if idx is not None else '' + dur = f' {self.dur_us:>6d}us' if with_dur else '' + body = self.data_hex(12) if self.data else '(无数据)' + return (f'{head}t={self.t0_us:9d}us{dur} {self.addr_hex()} ' + f'{self.rw_char()}: {body:<30s} ACK:{self.ack_str()}' + f'{self.flag_str()}') + + def __repr__(self): + return (f'Transaction(t0={self.t0_us}, addr=0x{self.addr7:02X}, ' + f'{self.rw_char()}, data={self.data_hex()}, acks={self.ack_str()}, ' + f'ended={self.ended})') + + +# --------------------------------------------------------------------------- +# 边沿序列 -> 事务(算法自 I2C-Inject i2c_sniff_decode.py, 真机验证过) +# --------------------------------------------------------------------------- + +def decode_edges(edges): + """边沿序列 -> [Transaction]。 + + edges: 可迭代的 (t_us, pins), pins bit0=SDA bit1=SCL, 值为新电平(变化沿)。 + 首个边沿只用于建立基线电平。规则: + SCL 高电平期间 SDA 下降 = START/repeated START(收掉当前事务, 新开一个); + SCL 高电平期间 SDA 上升 = STOP; + SCL 上升沿采样 SDA 为数据位, 第 9 位为应答(0=ACK)。 + 事务由 START..STOP 组成; repeated START 结束上一段(ended='rstart'); + 输入流耗尽仍未收尾的按 'cut'(若正好断在半字节则 'partial')。 + """ + return _decode_impl(edges) + + +def _decode_impl(edges): + """decode_edges 的真实实现(按位状态机, 与旧脚本语义一致)""" + trans = [] + cur = None # {'t0', 'bytes'[], 'acks'[], } + bits = [] + psda = pscl = None + t_last = 0 + + def close(t, ended): + nonlocal cur, bits + if cur is not None: + if bits and ended == 'cut': + ended = 'partial' + d = cur['bytes'] + trans.append(Transaction( + t0_us=cur['t0'], t1_us=t, addr7=(d[0] >> 1) if d else 0, + read=bool(d[0] & 1) if d else False, + acks=cur['acks'], data=bytes(d[1:]), ended=ended)) + cur = None + bits = [] + + for t, v in edges: + t_last = t + sda, scl = v & 1, (v >> 1) & 1 + if pscl is not None: + if scl == 1 and pscl == 1: # SCL 高电平期间 SDA 变化 + if psda == 1 and sda == 0: # START / repeated START + close(t, 'rstart') + cur = {'t0': t, 'bytes': [], 'acks': []} + elif psda == 0 and sda == 1: # STOP + close(t, 'stop') + elif scl == 1 and pscl == 0: # SCL 上升沿采样 + if cur is not None: + bits.append(sda) + if len(bits) == 8: + cur['bytes'].append( + int(''.join(map(str, bits)), 2)) + elif len(bits) == 9: # 第 9 位 = 应答(0=ACK) + cur['acks'].append(sda == 0) + bits = [] # 8 数据位不清, 凑满 9 位才清 + psda, pscl = sda, scl + close(t_last, 'cut') + return trans + + +# --------------------------------------------------------------------------- +# 事务 -> 边沿序列(参考编码器: 自测向量生成 + 对齐时序规定) +# --------------------------------------------------------------------------- + +class EdgeEncoder: + """把事务序列编码成 (t_us, pins) 边沿流。pins bit0=SDA bit1=SCL。 + + 定时(khz=100 → half=5us): SDA 在 SCL 变低后 1us 换位, SCL 高/低各 + half us。时间戳与 decode_edges 的采样规则严格对齐: + START 的 t0 = SDA 下降沿时刻, STOP 的 t1 = SDA 上升沿时刻。 + """ + + def __init__(self, khz=100.0, t0_us=1000): + self.half = max(1, round(500.0 / khz)) # 半周期 us + self.t = int(t0_us) + self.sda = 1 + self.scl = 1 + self.edges = [(self.t, self.pins())] # 初始基线电平 + + def pins(self): + return (self.scl << 1) | self.sda + + def _set(self, sda=None, scl=None, dt=1): + """电平目标: 只有真发生变化的沿才推进时间并记录(边沿流语义)""" + if sda is not None and sda != self.sda: + self.t += dt + self.sda = sda + self.edges.append((self.t, self.pins())) + if scl is not None and scl != self.scl: + self.t += dt + self.scl = scl + self.edges.append((self.t, self.pins())) + + def _bit(self, level): + """发一位: SCL 低电平窗口内置 SDA=level, SCL 走一拍(高 half 低 half)""" + self._set(sda=level, scl=0) + self._set(scl=1, dt=self.half) + self._set(scl=0, dt=self.half) + + def start(self): + """(repeated) START: SDA 高->低(SCL 高, t0 基准沿), 随后 SCL 拉低""" + self._set(sda=1) + self._set(scl=1) + self._set(sda=0, dt=self.half) # t0 基准沿 + t0 = self.t + self._set(scl=0, dt=self.half) + return t0 + + def stop(self): + """STOP: SCL 低时 SDA 置低, SCL 升高, SDA 升高(t1 基准沿)""" + self._set(sda=0, scl=0) + self._set(scl=1, dt=self.half) + self._set(sda=1, dt=self.half) # t1 基准沿 + return self.t + + def byte(self, value, ack): + """发 8 数据位 + 1 应答位(ack=True 为 ACK, 即 SDA=0)""" + for i in range(7, -1, -1): + self._bit((value >> i) & 1) + self._bit(0 if ack else 1) + return self.t + + +def encode_edges(transactions, khz=100.0, t0_us=1000, idle_us=50): + """事务列表 -> (edges, refs)。事务间 SDA/SCL 保持高(总线空闲 idle_us)。 + + ended 字段决定收尾: 'stop' 发 STOP; 'rstart' 直接进入下一个事务的 + repeated START; 'cut'/'partial' 发完最后字节悬停(仅自测用)。 + refs[i] = (t0, t1): t0 恒有; t1 仅 'stop' 时有(STOP 沿时刻, 与解码器 + 对 t1 的定义一致), 其余为 None。 + """ + enc = EdgeEncoder(khz=khz, t0_us=t0_us) + refs = [] + for k, tr in enumerate(transactions): + t0 = enc.start() + enc.byte(tr.addr7 << 1 | (1 if tr.read else 0), tr.acks[0]) + for i, b in enumerate(tr.data): + ack = tr.acks[i + 1] if i + 1 < len(tr.acks) else True + enc.byte(b, ack) + t1 = enc.stop() if tr.ended == 'stop' else None + if tr.ended == 'cut': + enc._set(scl=0) + refs.append((t0, t1)) + if k + 1 < len(transactions): + enc.t += idle_us # 空闲间隙(不出边沿) + return enc.edges, refs + + +# --------------------------------------------------------------------------- +# 固件监控流行解析(T/W 行, 语法见 firmware/main.c) +# --------------------------------------------------------------------------- + +def to_tline(tr, idx=None): + """Transaction -> 固件 T 行语法(本函数是语法权威之一, 与固件对齐): + + T + flags: 结束码 + 可选 'L'(长事务截断): '-' 'r' 'c' 'p' ( + 'L' ) + acks : n+1 个 A/N, 首个是地址应答; n=0 时地址应答仍保留 + """ + flags = {'stop': '-', 'rstart': 'r', 'cut': 'c', 'partial': 'p'}[tr.ended] + if tr.truncated: + flags += 'L' + n = len(tr.data) + body = ' '.join(f'{b:02X}' for b in tr.data) + return (f'T {tr.t0_us} {tr.dur_us} {tr.addr7:02X} {tr.rw_char()} ' + f'{flags} {tr.ack_str()} {n} {body}'.rstrip()) + + +def parse_tline(line): + """固件 T 行 -> Transaction; 非法行抛 ValueError。""" + parts = line.split() + if len(parts) < 7 or parts[0] != 'T': + raise ValueError(f'非 T 行: {line!r}') + try: + t0, dur = int(parts[1]), int(parts[2]) + addr = int(parts[3], 16) + rw = parts[4] + flags = parts[5] + acks_s = parts[6] + n = int(parts[7]) + data = bytes(int(x, 16) for x in parts[8:8 + n]) + except (IndexError, ValueError) as e: + raise ValueError(f'T 行字段非法: {line!r} ({e})') from e + if len(data) != n: + raise ValueError(f'T 行数据不足: 声明 {n} 实得 {len(data)}') + if rw not in ('R', 'W'): + raise ValueError(f'T 行方向非法: {rw!r}') + if not flags or len(flags) > 2 or flags[0] not in '-rcp': + raise ValueError(f'T 行 flags 非法: {flags!r}') + truncated = 'L' in flags[1:] + ended = {'-': 'stop', 'r': 'rstart', 'c': 'cut', 'p': 'partial'}[flags[0]] + if len(acks_s) != n + 1 or set(acks_s) - {'A', 'N'}: + raise ValueError(f'T 行 acks 长度/字符非法: {acks_s!r}') + return Transaction(t0_us=t0, t1_us=t0 + dur, addr7=addr, read=(rw == 'R'), + acks=tuple(c == 'A' for c in acks_s), data=data, + ended=ended, truncated=truncated) + + +def parse_wline(line): + """固件 W 行(边沿快照) -> (t_us, pins); 'W.' 结尾行返回 None。""" + if line == 'W.': + return None + parts = line.split() + if len(parts) != 3 or parts[0] != 'W': + raise ValueError(f'非 W 行: {line!r}') + t, pins = int(parts[1]), int(parts[2]) + if not 0 <= pins <= 3: + raise ValueError(f'W 行 pins 越界: {pins}') + return (t, pins) + + +# --------------------------------------------------------------------------- +# 导出 +# --------------------------------------------------------------------------- + +def transactions_to_csv(transactions): + """事务列表 -> CSV 文本(含表头; 数据列 hex 串, 应答列 A/N 串)""" + rows = ['idx,t0_us,dur_us,addr7,rw,addr_ack,data_hex,data_acks,ended'] + for i, tr in enumerate(transactions): + rows.append(','.join(( + str(i), str(tr.t0_us), str(tr.dur_us), f'{tr.addr7:02X}', + tr.rw_char(), 'A' if tr.addr_ack else 'N', + tr.data_hex(), ''.join('A' if a else 'N' for a in tr.data_acks), + tr.ended))) + return '\n'.join(rows) + '\n' diff --git a/I2C-Lab/src/main.py b/I2C-Lab/src/main.py new file mode 100644 index 0000000..ed6a353 --- /dev/null +++ b/I2C-Lab/src/main.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +I2C-Lab — I2C 总线监控调试台(Fluent UI) +================================================== +RP2040 probe 固件(被动监听)的上位机: 实时事务流 + 逻辑分析仪式波形快照。 + +功能: + - 串口连接固件(USB CDC), 一键开/停监控流(T 行) + - 事务表: 时间/时长/地址/RW/应答/数据/收尾标志, 地址过滤, 暂停滚动, + 清空, CSV 导出 + - 波形卡: wave 快照渲染 SDA/SCL 时序 + 事务注记(滚轮缩放/拖动/双击适配) + - 固件计数统计(边沿/事务/丢包); 演示数据模式(无硬件看界面) + - Light/Dark/跟随系统主题(DLPilot/SerialMate 三件套) + +用法: + python main.py 打开 GUI + python main.py --seconds N N 秒后自动退出(冒烟测试) + python main.py --demo 启动即开演示数据 +""" + +import argparse +import os +import queue +import random +import sys +import time + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QColor, QIcon +from PySide6.QtWidgets import (QApplication, QFileDialog, QHBoxLayout, + QHeaderView, QMainWindow, QMessageBox, + QSplitter, QTableWidget, QTableWidgetItem, + QVBoxLayout, QWidget) +from qfluentwidgets import (BodyLabel, CaptionLabel, CardWidget, CheckBox, + ComboBox, FluentIcon, LineEdit, PlainTextEdit, + PrimaryPushButton, PushButton, SpinBox, + StrongBodyLabel, SubtitleLabel) + +import config +import i2cdec +from i2cdec import Transaction +from probelink import ProbeLink +from theme import ThemeManager +from waveview import WaveView + +APP_NAME = 'I2C-Lab' +APP_VERSION = '0.1.0' +REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools' + +TABLE_CAP = 5000 # 表格行数上限(超出从顶删) +STORE_CAP = 20000 # CSV 导出保留的事务上限 +WAVE_N_DEFAULT = 2048 +WAVE_N_MAX = 16384 + + +def _now_ms(): + return int(time.time() * 1000) + + +class Window(QMainWindow): + + def __init__(self, theme_manager=None, seconds=0, demo=False): + super().__init__() + self.setWindowTitle(f'{APP_NAME} · I2C 总线监控调试台 v{APP_VERSION}') + self.resize(1380, 860) + self.setMinimumSize(1040, 640) + self.theme_manager = theme_manager or ThemeManager(QApplication.instance()) + self.cfg_store = config.load() + self.events = queue.Queue() + self.transactions = [] # 全量事务(CSV 用, 环形) + self._trans_total = self._trans_shown = 0 + self._fw_stats = '' + self._demo_edges = [] # 演示模式的滚动边沿流 + self._demo_t = 100_000 + self._paused = False + + self.link = ProbeLink(on_status=lambda s: self.events.put(('status', s))) + self.link.on_trans = lambda tr: self.events.put(('trans', tr)) + self.link.on_wave = lambda e, d: self.events.put(('wave', e, d)) + self.link.on_info = lambda l: self.events.put(('info', l)) + + self._build_ui() + self._apply_store_to_ui() + self.theme_manager.set_window(self) + + poll = QTimer(self) + poll.timeout.connect(self._drain_events) + poll.start(30) + self._demo_timer = QTimer(self) + self._demo_timer.timeout.connect(self._demo_tick) + if demo: + self._toggle_demo() + if seconds: + QTimer.singleShot(int(seconds * 1000), self.close) + + # ------------------------------------------------------------------ UI + + def _build_ui(self): + root = QWidget(self) + root.setObjectName('root') + outer = QVBoxLayout(root) + outer.setContentsMargins(18, 12, 18, 12) + outer.setSpacing(10) + + head = QHBoxLayout() + box = QVBoxLayout() + box.setSpacing(1) + box.addWidget(SubtitleLabel(APP_NAME)) + box.addWidget(CaptionLabel( + f'I2C 总线监控调试台 · RP2040 被动监听 · v{APP_VERSION}')) + head.addLayout(box) + head.addStretch(1) + self.btn_demo = PushButton('演示数据') + self.btn_demo.setToolTip('无硬件演示: 模拟 LT8619C 轮询流量(表格/波形全链路)') + self.btn_demo.clicked.connect(self._toggle_demo) + head.addWidget(self.btn_demo) + btn_about = PushButton('关于') + btn_about.clicked.connect(self.show_about) + head.addWidget(btn_about) + self.combo_theme = ComboBox() + for m in ('跟随系统', '浅色', '深色'): + self.combo_theme.addItem(m) + self.combo_theme.setCurrentIndex(0) + self.combo_theme.currentIndexChanged.connect(self._on_theme) + head.addWidget(BodyLabel('主题')) + head.addWidget(self.combo_theme) + outer.addLayout(head) + + splitter = QSplitter(Qt.Horizontal) + left, right = QWidget(), QWidget() + lcol, rcol = QVBoxLayout(left), QVBoxLayout(right) + lcol.setContentsMargins(0, 0, 0, 0) + rcol.setContentsMargins(0, 0, 0, 0) + lcol.setSpacing(10) + rcol.setSpacing(10) + self._build_serial_card(lcol) + self._build_mon_card(lcol) + self._build_log_card(lcol) + lcol.addStretch(1) + rsplit = QSplitter(Qt.Vertical) + rsplit.setHandleWidth(6) + rsplit.setChildrenCollapsible(False) + rsplit.addWidget(self._build_table_card()) + rsplit.addWidget(self._build_wave_card()) + rsplit.setStretchFactor(0, 1) + rsplit.setStretchFactor(1, 1) + rsplit.setSizes((420, 320)) + rcol.addWidget(rsplit) + splitter.addWidget(left) + splitter.addWidget(right) + splitter.setSizes((400, 960)) + outer.addWidget(splitter, 1) + self.setCentralWidget(root) + self.statusBar().showMessage('未连接') + + def _card(self, title, subtitle=None, side=False): + card = CardWidget(self) + lay = QVBoxLayout(card) + lay.setContentsMargins(14, 12, 14, 14) + lay.setSpacing(8) + if subtitle and side: + h = QHBoxLayout() + h.setSpacing(10) + h.addWidget(StrongBodyLabel(title, card)) + h.addStretch(1) + h.addWidget(CaptionLabel(subtitle, card)) + lay.addLayout(h) + return card, lay + lay.addWidget(StrongBodyLabel(title, card)) + if subtitle: + c = CaptionLabel(subtitle, card) + c.setWordWrap(True) + lay.addWidget(c) + return card, lay + + @staticmethod + def _row(lay, *widgets, trailing=True): + h = QHBoxLayout() + h.setSpacing(6) + for w in widgets: + h.addWidget(w) + if trailing: + h.addStretch(1) + lay.addLayout(h) + return h + + # --- 串口卡 --- + + def _build_serial_card(self, col): + card, lay = self._card('固件串口', 'USB CDC, 参数固定; [RP2040] 标记为建议端口', + side=True) + self.combo_port = ComboBox() + self.combo_port.setMinimumWidth(170) + self._port_devs = [] + self.btn_refresh = PushButton('刷新') + self.btn_refresh.setIcon(FluentIcon.SYNC) + self.btn_refresh.clicked.connect(self._refresh_ports) + self.btn_conn = PrimaryPushButton('连接') + self.btn_conn.clicked.connect(self._toggle_connect) + h = self._row(lay, self.combo_port, self.btn_refresh, self.btn_conn, + trailing=False) + h.setStretch(0, 1) + self.lb_state = BodyLabel('未连接') + self.lb_state.setWordWrap(True) + lay.addWidget(self.lb_state) + col.addWidget(card) + self._refresh_ports() + + def _refresh_ports(self): + self._port_devs = ProbeLink.list_ports_info() + combo = self.combo_port + combo.clear() + for _dev, label in self._port_devs: + combo.addItem(label) + if not self._port_devs: + combo.addItem('未检测到串口') + + def _toggle_connect(self): + if self.link.is_connected(): + self.link.close() + self.btn_conn.setText('连接') + return + if not self._port_devs: + self._hint('未检测到串口, 插入 probe 后刷新') + return + idx = self.combo_port.currentIndex() + if not 0 <= idx < len(self._port_devs): + self._hint('请选择串口') + return + self.link.open(self._port_devs[idx][0]) + self.btn_conn.setText('断开') + + # --- 监控卡 --- + + def _build_mon_card(self, col): + card, lay = self._card('监控', 'probe 纯被动监听: 不加线上拉, 不驱动总线') + self.btn_mon = PrimaryPushButton('开始监控') + self.btn_mon.setIcon(FluentIcon.VIDEO) + self.btn_mon.clicked.connect(self._toggle_mon) + self.btn_pause = PushButton('暂停滚动') + self.btn_pause.clicked.connect(self._toggle_pause) + btn_clear = PushButton('清空') + btn_clear.setIcon(FluentIcon.DELETE) + btn_clear.clicked.connect(self._clear_table) + self.btn_csv = PushButton('导出CSV') + self.btn_csv.setIcon(FluentIcon.SAVE) + self.btn_csv.clicked.connect(self._save_csv) + self._row(lay, self.btn_mon, self.btn_pause, btn_clear, self.btn_csv) + + self.ed_filter = LineEdit() + self.ed_filter.setPlaceholderText('如 32 50(十六进制, 空格分隔; 空=全部)') + self.ed_filter.setFixedWidth(200) + self.lb_filter = CaptionLabel('') + self._row(lay, CaptionLabel('地址过滤'), self.ed_filter, self.lb_filter) + + self.lb_stats = CaptionLabel('事务 0 | 表内 0') + self.lb_stats.setWordWrap(True) + lay.addWidget(self.lb_stats) + self.lb_fw = CaptionLabel('固件计数: 未连接') + self.lb_fw.setWordWrap(True) + lay.addWidget(self.lb_fw) + col.addWidget(card) + + def _toggle_mon(self): + if self._demo_timer.isActive(): + self._hint('演示模式运行中, 先停演示再连真机') + return + on = self.btn_mon.text().startswith('开始') + self.link.send_line('mon start' if on else 'mon stop') + self.btn_mon.setText('停止监控' if on else '开始监控') + + def _toggle_pause(self): + self._paused = not self._paused + self.btn_pause.setText('继续滚动' if self._paused else '暂停滚动') + + def _addr_filter(self): + """过滤输入 -> {addr7:int} 集合; 非法输入提示并返回 None""" + text = self.ed_filter.text().strip() + if not text: + self.lb_filter.setText('') + return None + out = set() + for tok in text.replace(',', ' ').split(): + try: + out.add(int(tok, 16)) + except ValueError: + self.lb_filter.setText(f'忽略非法项 {tok!r}') + return out + + def _clear_table(self): + self.table.setRowCount(0) + self._trans_shown = 0 + self._update_stats() + + def _save_csv(self): + path, _ = QFileDialog.getSaveFileName(self, '导出事务 CSV', + 'i2c_transactions.csv', + 'CSV 文件 (*.csv)') + if not path: + return + try: + with open(path, 'w', encoding='utf-8-sig') as f: + f.write(i2cdec.transactions_to_csv(self.transactions)) + self._hint(f'已导出 {len(self.transactions)} 条事务: {path}') + except OSError as e: + self._hint(f'导出失败: {e}') + + # --- 固件日志卡 --- + + def _build_log_card(self, col): + card, lay = self._card('固件消息', side=True) + self.text_log = PlainTextEdit() + self.text_log.setReadOnly(True) + self.text_log.setFont(self._mono_font()) + self.text_log.document().setMaximumBlockCount(800) + self.text_log.setMinimumHeight(88) + self.text_log.setMaximumHeight(150) + lay.addWidget(self.text_log) + col.addWidget(card) + + @staticmethod + def _mono_font(): + from PySide6.QtGui import QFontDatabase + return QFontDatabase.systemFont(QFontDatabase.FixedFont) + + # --- 事务表卡 --- + + def _build_table_card(self): + card, lay = self._card('事务流', 'T 行实时解码: 每行一个 START..收尾 的事务') + self.table = QTableWidget(0, 8) + self.table.setHorizontalHeaderLabels( + ['#', 't0(us)', '时长us', '地址', 'RW', 'ACK', '数据', '收尾']) + hdr = self.table.horizontalHeader() + hdr.setSectionResizeMode(0, QHeaderView.ResizeToContents) + for c in (1, 2, 3, 4): + hdr.setSectionResizeMode(c, QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(5, QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(6, QHeaderView.Stretch) + hdr.setSectionResizeMode(7, QHeaderView.ResizeToContents) + self.table.verticalHeader().setVisible(False) + self.table.verticalHeader().setDefaultSectionSize(26) + self.table.setEditTriggers(QTableWidget.NoEditTriggers) + self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.setAlternatingRowColors(True) + self.table.setFont(self._mono_font()) + lay.addWidget(self.table, 1) + return card + + def _add_trans_row(self, tr: Transaction, idx: int): + t = self.table + t.insertRow(t.rowCount()) + r = t.rowCount() - 1 + ended_label = {'stop': 'STOP', 'rstart': '重START', 'cut': '截断', + 'partial': '半字节'}[tr.ended] + if tr.truncated: + ended_label += '+截长' + data_str = tr.data_hex(16) + cells = (str(idx), str(tr.t0_us), str(tr.dur_us), tr.addr_hex(), + tr.rw_char(), tr.ack_str(), data_str or '—', ended_label) + for c, txt in enumerate(cells): + it = QTableWidgetItem(txt) + if c == 6 and len(tr.data) > 16: + it.setToolTip(tr.data_hex()) + if c == 5 and tr.acks and not tr.addr_ack: + it.setForeground(QColor('#e0564c')) # 地址 NAK 标红 + if c == 7 and tr.ended not in ('stop', 'rstart'): + it.setForeground(QColor('#e08a2e')) + it.setFlags(it.flags() & ~Qt.ItemIsEditable) + t.setItem(r, c, it) + if t.rowCount() > TABLE_CAP: + t.removeRow(0) + if not self._paused: + t.scrollToBottom() + + # --- 波形卡 --- + + def _build_wave_card(self): + card, lay = self._card('波形快照', + '逻辑分析仪视图: 滚轮缩放(光标锚点) · 拖动平移 · ' + '双击适配; 绿/红虚线=START/STOP') + self.wave = WaveView() + lay.addWidget(self.wave, 1) + self.spin_wave_n = SpinBox() + self.spin_wave_n.setRange(256, WAVE_N_MAX) + self.spin_wave_n.setSingleStep(256) + self.spin_wave_n.setValue(WAVE_N_DEFAULT) + self.btn_wave = PrimaryPushButton('抓取波形') + self.btn_wave.setIcon(FluentIcon.CAMERA) + self.btn_wave.clicked.connect(self._grab_wave) + btn_fit = PushButton('适配全宽') + btn_fit.clicked.connect(self.wave.fit_all) + self.lb_wave = CaptionLabel('未抓取') + h = self._row(lay, self.btn_wave, CaptionLabel('边沿数'), self.spin_wave_n, + btn_fit, self.lb_wave) + h.setStretch(3, 0) + return card + + def _grab_wave(self): + n = self.spin_wave_n.value() + if self._demo_timer.isActive(): + edges = self._demo_edges[-n:] + self._on_wave(edges, 0) + return + self.link.send_line(f'wave {n}') + self.lb_wave.setText(f'请求 {n} 边沿…') + + # ------------------------------------------------------- 演示模式 + + def _toggle_demo(self): + if self._demo_timer.isActive(): + self._demo_timer.stop() + self.btn_demo.setText('演示数据') + self._hint('演示已停止') + else: + self._demo_timer.start(300) + self.btn_demo.setText('停止演示') + self._hint('演示模式: 模拟 LT8619C demo 板 ~500ms 轮询流量') + + def _demo_tick(self): + """模拟 demo 板流量: 每 tick 生成一批事务, 同时积累边沿供波形抓取""" + rng = random.Random() + fam = [] + bank = rng.choice((0x60, 0x61)) + reg = rng.choice((0x6E, 0x6D, 0x44)) + fam.append(Transaction(addr7=0x32, read=False, acks=(True, True, True), + data=bytes((0xFF, bank)))) + fam.append(Transaction(addr7=0x32, read=False, + acks=(True, True) + ((True,) if rng.random() < 0.7 else ()), + data=bytes((reg, rng.randrange(256))) + if rng.random() < 0.7 else bytes((reg,)))) + if rng.random() < 0.4: # 偶尔一次读回链 + fam.append(Transaction(addr7=0x32, read=False, acks=(True, True), + data=bytes((reg,)), ended='rstart')) + fam.append(Transaction(addr7=0x32, read=True, acks=(True, False), + data=bytes((rng.randrange(256),)))) + edges, _refs = i2cdec.encode_edges(fam, khz=100.0, t0_us=self._demo_t) + shift = edges[0][0] - self._demo_t + edges = [(t - shift + self._demo_t, v) for t, v in edges] + self._demo_t = edges[-1][0] + 500_000 # ~500ms 轮询间隔 + self._demo_edges.extend(edges) + if len(self._demo_edges) > 6 * WAVE_N_MAX: + del self._demo_edges[:len(self._demo_edges) - 4 * WAVE_N_MAX] + for tr in i2cdec.decode_edges(edges): + self.events.put(('trans', tr)) + + # ------------------------------------------------------- 事件 + + def _drain_events(self): + for _ in range(500): + try: + ev = self.events.get_nowait() + except queue.Empty: + return + kind = ev[0] + if kind == 'status': + self._on_status(ev[1]) + elif kind == 'trans': + self._on_trans(ev[1]) + elif kind == 'wave': + self._on_wave(ev[1], ev[2]) + elif kind == 'info': + self._on_info(ev[1]) + + def _hint(self, text): + self.lb_state.setText(text) + self.statusBar().showMessage(text, 8000) + self.text_log.appendPlainText(f'· {text}') + + def _on_status(self, text): + if text.startswith('已连接'): + self.btn_conn.setText('断开') + self.link.send_line('v') + self.link.send_line('stats') + elif text.startswith(('已断开', '连接失败', '串口丢失')): + self.btn_conn.setText('连接') + self._hint(text) + + def _on_trans(self, tr): + self._trans_total += 1 + self.transactions.append(tr) + if len(self.transactions) > STORE_CAP: + del self.transactions[:len(self.transactions) - STORE_CAP] + flt = self._addr_filter() + if flt is not None and tr.addr7 not in flt: + self._update_stats() + return + self._trans_shown += 1 + self._add_trans_row(tr, self._trans_total) + self._update_stats() + + def _on_wave(self, edges, dropped): + self.wave.set_edges(edges) + n = len(edges) + try: + trans = i2cdec.decode_edges(edges) + except Exception: + trans = [] + msg = f'{n} 边沿, 解码 {len(trans)} 事务' + if dropped: + msg += f', 缺 {dropped}' + self.lb_wave.setText(msg) + self._hint(f'波形快照: {msg}') + + def _on_info(self, line): + self.text_log.appendPlainText(line) + if line.startswith('= stats '): + self._fw_stats = line[len('= stats '):] + self.lb_fw.setText(f'固件计数: {self._fw_stats}') + + def _update_stats(self): + self.lb_stats.setText(f'事务 {self._trans_total} | 表内 {self._trans_shown}' + f' | 导出池 {len(self.transactions)}') + self.statusBar().showMessage( + f'{APP_NAME} v{APP_VERSION} 事务 {self._trans_total}') + + def _on_theme(self, idx): + self.theme_manager.set_mode(('system', 'light', 'dark')[idx]) + + def show_about(self): + QMessageBox.about( + self, f'关于 {APP_NAME}', + f'{APP_NAME} v{APP_VERSION}\n\n' + 'I2C 总线监控调试台 · RP2040 被动监听(PIO 捕获 + 实时解码)\n' + '实时事务流 + 逻辑分析仪式波形快照 + CSV 导出\n' + '接线: GP8=SDA GP9=SCL 共地(纯被动, 不加上拉)\n\n' + 'PySide6-Fluent-Widgets + pyserial · 固件 pico-sdk C\n' + f'{REPO_URL}') + + # ------------------------------------------------------- 配置进出 + + def _apply_store_to_ui(self): + m = self.cfg_store['mon'] + self.ed_filter.setText(m.get('addr_filter', '')) + self.spin_wave_n.setValue(int(m.get('wave_n', WAVE_N_DEFAULT))) + s = self.cfg_store['serial'] + if s.get('port'): + for i, (dev, _label) in enumerate(self._port_devs): + if dev == s['port']: + self.combo_port.setCurrentIndex(i) + break + + def _store_from_ui(self): + idx = self.combo_port.currentIndex() + port = self._port_devs[idx][0] if 0 <= idx < len(self._port_devs) else '' + return { + 'serial': {'port': port}, + 'mon': {'addr_filter': self.ed_filter.text().strip(), + 'paused': self._paused, + 'wave_n': self.spin_wave_n.value()}, + } + + def closeEvent(self, ev): + self._demo_timer.stop() + self.link.shutdown() # join 工作线程, 防解释器关闭期崩溃 + config.save(self._store_from_ui()) + super().closeEvent(ev) + + +def icon_path(): + """应用图标: exe(onefile)在 _MEIPASS/assets, 源码运行在工具根 assets/""" + if hasattr(sys, '_MEIPASS'): + return os.path.join(sys._MEIPASS, 'assets', 'i2c-lab.ico') + return os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'assets', 'i2c-lab.ico') + + +def main(): + ap = argparse.ArgumentParser(description='I2C-Lab I2C 总线监控调试台') + ap.add_argument('--seconds', type=float, default=0, + help='N 秒后自动退出(冒烟测试)') + ap.add_argument('--demo', action='store_true', help='启动即开演示数据') + args = ap.parse_args() + app = QApplication(sys.argv) + app.setWindowIcon(QIcon(icon_path())) + win = Window(seconds=args.seconds, demo=args.demo) + win.show() + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/I2C-Lab/src/package_release.py b/I2C-Lab/src/package_release.py new file mode 100644 index 0000000..d7f7295 --- /dev/null +++ b/I2C-Lab/src/package_release.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +组装 I2C-Lab 发布包: exe + 固件 UF2 + README → 一个 zip(解压即用) +================================================ +把主 README 与 probe 固件 UF2 复制进 release/ 后, 与单文件 exe 一起压 zip。 + +zip 解压后根目录为 I2C-Lab/: + I2C-Lab.exe 主程序(先运行 build_exe.py 打包) + firmware/i2c_lab.uf2 probe 固件(BOOTSel 拖入刷写) + README.md 使用说明 + +用法: + python package_release.py 版本号自动取最近 git tag(I2C-Lab-v*) + python package_release.py 0.1.0 手动指定版本号 +产物: release/I2C-Lab-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release) +""" + +import os +import shutil +import subprocess +import sys +import zipfile + +SRC = os.path.dirname(os.path.abspath(__file__)) +BASE = os.path.dirname(SRC) +RELEASE = os.path.join(BASE, 'release') + + +def detect_version(): + """从最近的 I2C-Lab-v* tag 推断版本号, 无 tag 则报错提示手动指定""" + try: + out = subprocess.check_output( + ['git', 'describe', '--tags', '--abbrev=0', '--match', 'I2C-Lab-v*'], + cwd=BASE, stderr=subprocess.DEVNULL) + return out.decode().strip().removeprefix('I2C-Lab-v') + except Exception: + sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>') + + +def main(): + version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v') + exe = os.path.join(RELEASE, 'I2C-Lab.exe') + readme = os.path.join(BASE, 'README.md') + uf2 = os.path.join(RELEASE, 'firmware', 'i2c_lab.uf2') + if not os.path.exists(exe): + sys.exit('缺少 release/I2C-Lab.exe, 先运行: python build_exe.py') + if not os.path.exists(readme): + sys.exit(f'缺少 {readme}') + if not os.path.exists(uf2): + sys.exit('缺少 release/firmware/i2c_lab.uf2, 先按 firmware/CMakeLists 注释构建') + + shutil.copy2(readme, os.path.join(RELEASE, 'README.md')) + zip_name = f'I2C-Lab-v{version}-win64.zip' + zip_path = os.path.join(RELEASE, zip_name) + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z: + z.write(exe, f'I2C-Lab/{os.path.basename(exe)}') + z.write(uf2, 'I2C-Lab/firmware/i2c_lab.uf2') + z.write(os.path.join(RELEASE, 'README.md'), 'I2C-Lab/README.md') + print(f'发布文件夹: {RELEASE} (exe + firmware + README)') + print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)') + print('下一步上传: python publish_gitea.py', zip_name) + + +if __name__ == '__main__': + main() diff --git a/I2C-Lab/src/probelink.py b/I2C-Lab/src/probelink.py new file mode 100644 index 0000000..fbb94fd --- /dev/null +++ b/I2C-Lab/src/probelink.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +I2C-Lab probe 固件链路线程(USB CDC, 行协议) +================================================== +与 SerialMate link.py 同一套路(工作线程 + 意图驱动开合 + 回调投递), 差异: + - 固件是文本行协议(T 事务流 / W 边沿快照 / '=' 应答 / '!' 告警), 按行分发; + - USB CDC 不挑波特率(115200 仅占位), 参数固定 8N1; + - W 行成段接收: '= wave n' 头之后收集 n 条, 'W.' 收尾后一次性回调 + (与固件 cmd_wave 输出顺序对齐)。 + +回调全在后台线程触发, GUI 侧必须投递回主线程(见 main.py 的事件队列)。 +pyserial 为本模块独占依赖(缺失只影响实机链路)。 +""" + +import queue +import threading +import time + +try: + import serial + import serial.tools.list_ports + HAS_SERIAL = True +except ImportError: + HAS_SERIAL = False + +from i2cdec import parse_tline, parse_wline + +VENDOR_HINTS = ('2e8a',) # RP2040 USB VID(枚举标签辅助, 不做过滤) + + +class ProbeLink: + """固件串口工作线程。 + + 回调(后台线程触发): + on_status(str) 连接状态变化 + on_trans(Transaction) 一条 T 行解码出的事务 + on_wave(edges, dropped) 一次 wave 快照完成: edges=[(t_us,pins)..] + on_info(line) '='/'!' 应答/告警行(原文) + """ + + def __init__(self, on_status): + self.on_status = on_status + self.on_trans = None + self.on_wave = None + self.on_info = None + self.q = queue.Queue() + self._target = None # 意图 port + self._open_key = None + self.ser = None + self._lock = threading.Lock() + self._tx_q = queue.Queue() + self._wave = None # (n_expect, [edges..]) + self._stop = threading.Event() # 退出信号(解释器关闭前必须 join) + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def shutdown(self): + """退出用: 断开并 join 工作线程(防止解释器关闭期 daemon 线程访问违例)""" + self.close() + self._stop.set() + self._thread.join(timeout=2.0) + + # --- 串口管理 --- + + @staticmethod + def list_ports_info(): + """枚举串口 [(设备名, 显示标签)..]; RP2040 CDC 描述带 [RP2040] 提示""" + if not HAS_SERIAL: + return [] + out = [] + for p in serial.tools.list_ports.comports(): + desc = (p.description or '').strip() + if desc.upper() == p.device.upper(): + desc = '' + label = f'{p.device} {desc}'.rstrip() + if p.vid and f'{p.vid:04x}' in VENDOR_HINTS: + label += ' [RP2040]' + out.append((p.device, label)) + return out + + def open(self, port): + if not HAS_SERIAL: + self.on_status('未安装 pyserial: py -m pip install pyserial') + return + with self._lock: + self._target = port + + def close(self): + with self._lock: + self._target = None + + def is_connected(self): + with self._lock: + return self.ser is not None + + # --- 发送(命令行) --- + + def send_line(self, line): + """发一行命令(自动补换行); 未连接时丢弃并提示""" + with self._lock: + ser = self.ser + if ser is None: + self.on_status('未连接, 命令被丢弃') + return False + self._tx_q.put(line.encode('ascii', 'replace') + b'\n') + return True + + # --- 工作线程 --- + + def _run(self): + buf = b'' + while not self._stop.is_set(): + with self._lock: + target = self._target + cur = self._open_key + if target != cur: + if self.ser is not None: + try: + self.ser.close() + except Exception: + pass + self.ser = None + self._open_key = None + self.on_status('已断开') + if target: + try: + self.ser = serial.Serial(target, 115200, + timeout=0.15, + write_timeout=1.0) + with self._lock: + self._open_key = target + self.on_status(f'已连接 {target}') + except Exception as e: + self.ser = None + with self._lock: + self._target = None + et = str(e) + if '拒绝访问' in et or 'Access is denied' in et: + et += '(串口被占用: 检查其他工具/终端)' + self.on_status(f'连接失败: {et}') + if self.ser is not None: + try: + try: + line = self._tx_q.get_nowait() + self.ser.write(line) + except queue.Empty: + pass + data = self.ser.read(4096) + if data: + buf = self._dispatch(buf + data) + except OSError: + try: + self.ser.close() + except Exception: + pass + self.ser = None + self._open_key = None + with self._lock: + self._target = None + self.on_status('串口丢失(设备拔出?), 请重新连接') + buf = b'' + self._stop.wait(0.01) # 可被打断的 sleep(退出即醒) + + def _dispatch(self, buf): + """按行切分并分类; 返回残余半行缓冲""" + while True: + nl = buf.find(b'\n') + if nl < 0: + return buf + raw, buf = buf[:nl], buf[nl + 1:] + line = raw.decode('ascii', 'replace').rstrip('\r') + if not line: + continue + try: + if line.startswith('T '): + if self.on_trans is not None: + self.on_trans(parse_tline(line)) + elif line.startswith('W '): + e = parse_wline(line) + if e is not None and self._wave is not None: + self._wave[1].append(e) + elif line == 'W.': + if self._wave is not None: + n_expect, edges = self._wave + self._wave = None + if self.on_wave is not None: + self.on_wave(edges, len(edges) - n_expect + if len(edges) < n_expect else 0) + elif line.startswith('= wave '): + try: + n = int(line.split()[2]) + except (IndexError, ValueError): + n = 0 + self._wave = (n, []) + else: + if self.on_info is not None: + self.on_info(line) + except ValueError as e: + # 坏行不致命: 记入状态提示并继续 + if self.on_info is not None: + self.on_info(f'! 解析失败 {e}: {line[:60]!r}') diff --git a/I2C-Lab/src/publish_gitea.py b/I2C-Lab/src/publish_gitea.py new file mode 100644 index 0000000..dacab22 --- /dev/null +++ b/I2C-Lab/src/publish_gitea.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖) +================================================================ +用法: + python publish_gitea.py I2C-Lab-v0.1.0-win64.zip + python publish_gitea.py --notes-file release/notes-v0.1.0.md + python publish_gitea.py --tag I2C-Lab-v0.1.0 --replace + +行为: + - tag 与版本默认取 zip 文件名(I2C-Lab-vX.Y.Z-win64.zip → I2C-Lab-vX.Y.Z) + - release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag); + 已存在则复用, 仅上传/更新资产 + - release 上已有同名资产时: 默认跳过, --replace 删旧再传 + - API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限) + - 本实例 TLS 间歇抽风: 已内置连接级重试, 实际发布请加 --insecure +""" + +import argparse +import json +import os +import re +import ssl +import subprocess +import sys +import time +import urllib.error +import urllib.request +import uuid + +DEFAULT_NOTES = """## I2C-Lab {version} + +I2C 总线监控调试台(LT8619C 调试工具集配套): RP2040 probe 固件被动监听 +I2C 总线(PIO 硬件捕获 + 实时解码, 不加上拉不驱动), PC 端实时事务流 +(地址/RW/ACK/数据/收尾)、地址过滤、CSV 导出与逻辑分析仪式波形快照 +(滚轮缩放/拖动/事务注记); 主机读写与从机模拟为后续计划。 + +### 安装 +下载 `I2C-Lab-v{version}-win64.zip`, 解压后双击 `I2C-Lab.exe` +(Windows 10/11 x64, 免安装; 串口功能需系统可访问串口, 程序自带 pyserial)。 + +### 包内容 +- `I2C-Lab.exe` — 主程序 +- `firmware/i2c_lab.uf2` — probe 固件(按住 BOOTSEL 上电, 拖入 RPI-RP2 盘) +- `README.md` — 完整使用说明 +""" + + +def git_remote_info(): + """从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)""" + url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip() + m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url) + if not m: + sys.exit(f'无法从 origin 解析 Gitea 地址: {url}') + host, owner, repo = m.groups() + return f'https://{host}', owner, repo + + +class Api: + def __init__(self, base, owner, repo, token, insecure): + self.api = f'{base}/api/v1/repos/{owner}/{repo}' + self.headers = {'Authorization': f'token {token}'} + self.ctx = ssl._create_unverified_context() if insecure else None + + def request(self, method, path, data=None, headers=None, retries=4): + """发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试""" + last = None + for attempt in range(retries): + req = urllib.request.Request(self.api + path, data=data, method=method, + headers={**self.headers, **(headers or {})}) + try: + with urllib.request.urlopen(req, context=self.ctx) as resp: + body = resp.read() + return resp.status, json.loads(body) if body else None + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or b'{}') + except (urllib.error.URLError, ssl.SSLError, OSError) as e: + last = e + time.sleep(1.0 + attempt) + raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}') + + def get_json(self, path): + status, data = self.request('GET', path) + return data if status == 200 else None + + def upload_asset(self, release_id, filepath, replace): + name = os.path.basename(filepath) + status, rel = self.request('GET', f'/releases/{release_id}') + if status != 200: + sys.exit(f'读取 release {release_id} 失败: {status} {rel}') + # Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组 + assets = rel.get('assets') or [] + if isinstance(assets, dict): + assets = assets.get('items', []) + for asset in assets: + if asset['name'] == name: + if not replace: + print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)') + return + self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}") + print(f'已删除旧资产 {name}') + boundary = uuid.uuid4().hex + payload = open(filepath, 'rb').read() + body = (f'--{boundary}\r\n' + f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n' + f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \ + f'\r\n--{boundary}--\r\n'.encode() + status, data = self.request( + 'POST', f'/releases/{release_id}/assets?name={name}', body, + {'Content-Type': f'multipart/form-data; boundary={boundary}'}) + if status != 201: + sys.exit(f'上传资产失败: {status} {data}') + print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)') + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('zipfile_path', help='发布 zip(release/ 下)') + ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip') + ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板') + ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token') + ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产') + ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)') + args = ap.parse_args() + + if not os.path.exists(args.zipfile_path): + sys.exit(f'找不到 {args.zipfile_path}') + if not args.token: + sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN') + + tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path)) + version = tag.removeprefix('I2C-Lab-v') + notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \ + else DEFAULT_NOTES.format(version=version) + + base, owner, repo = git_remote_info() + api = Api(base, owner, repo, args.token, args.insecure) + print(f'目标: {base}/{owner}/{repo} tag={tag}') + + rel = api.get_json(f'/releases/tags/{tag}') + if rel: + print(f'release 已存在(id={rel["id"]}), 复用') + else: + status, rel = api.request('POST', '/releases', json.dumps({ + 'tag_name': tag, + 'name': f'I2C-Lab v{version}', + 'body': notes, + }).encode(), {'Content-Type': 'application/json'}) + if status != 201: + sys.exit(f'创建 release 失败: {status} {rel}') + print(f'release 已创建(id={rel["id"]})') + api.upload_asset(rel['id'], args.zipfile_path, args.replace) + print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}') + + +if __name__ == '__main__': + main() diff --git a/I2C-Lab/src/selftest.py b/I2C-Lab/src/selftest.py new file mode 100644 index 0000000..6bbf8e2 --- /dev/null +++ b/I2C-Lab/src/selftest.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""i2cdec.py 协议自检(无硬件): 编码→解码往返 + T/W 行语法 + 鲁棒性 + +用法: python selftest.py (全部通过退出码 0) +覆盖: + 1. 典型事务族(写/读/rep-START 链/quick/地址NAK)编码→解码 内容+时间戳往返 + 2. 100k/400k 两种速率下时间戳对齐 + 3. decode_edges 对随机边沿流不崩溃(畸形输入) + 4. T 行 to/parse 往返 + 各 ended 标志 + 5. W 行解析与结束符 + 6. CSV 导出形状 +""" +import random +import sys + +import i2cdec as d + +fails = [] + + +def check(name, got, want): + ok = got == want + print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})') + if not ok: + fails.append(name) + + +def check_true(name, cond, detail=''): + ok = bool(cond) + print(('PASS' if ok else 'FAIL'), name, '->', detail) + if not ok: + fails.append(name) + + +T = d.Transaction + +# 1) 典型事务族: 编码→解码 往返(内容键 + t0/t1 时间戳) +fam = [ + T(t0_us=0, addr7=0x32, read=False, acks=(True, True, True), + data=bytes([0xFF, 0x60]), ended='stop'), # 切 bank 写 + T(t0_us=0, addr7=0x32, read=True, acks=(True, False), + data=bytes([0x80]), ended='stop'), # 读回, 主机 NAK 结束 + T(t0_us=0, addr7=0x50, read=False, acks=(True, True), data=b'\x00', + ended='rstart'), # 写寄存器号 + T(t0_us=0, addr7=0x50, read=True, acks=(True, True, True), + data=b'\x12\x34', ended='stop'), # repeated-START 读 + T(t0_us=0, addr7=0x10, read=False, acks=(False,), data=b'', + ended='stop'), # quick: 地址 NAK + T(t0_us=0, addr7=0x68, read=True, acks=(True,), data=b'', + ended='stop'), # quick 读 + T(t0_us=0, addr7=0x2A, read=False, acks=(True,) + (True,) * 16, + data=bytes(range(16)), ended='stop'), # 长写 16 字节 +] +for khz in (100.0, 400.0): + edges, refs = d.encode_edges(fam, khz=khz) + got = d.decode_edges(edges) + check(f'roundtrip count @{khz}kHz', len(got), len(fam)) + for i, (g, w) in enumerate(zip(got, fam)): + check(f'tr[{i}] key @{khz}kHz', g.key(), w.key()) + for i, (g, (t0, t1)) in enumerate(zip(got, refs)): + check(f'tr[{i}] t0 @{khz}kHz', g.t0_us, t0) + if t1 is not None: + check(f'tr[{i}] t1 @{khz}kHz', g.t1_us, t1) + +# 2) 解码出的非 stop 事务 t1 定义: rstart 段 t1=下一事务 START 沿 +edges, _refs = d.encode_edges(fam) +got = d.decode_edges(edges) +check('rstart ended', got[2].ended, 'rstart') +check_true('rstart t1 == next t0', got[2].t1_us == got[3].t0_us, + f'{got[2].t1_us} vs {got[3].t0_us}') + +# 3) 流截断(cut)与半字节断(partial) +cut_fam = [ + T(t0_us=0, addr7=0x32, read=False, acks=(True, True), data=b'\x6E', + ended='cut'), # 整字节后悬停 +] +edges, _ = d.encode_edges(cut_fam) +got = d.decode_edges(edges) +check('cut ended', got[0].ended, 'cut') +check('cut key', got[0].key(), cut_fam[0].key()) + +# 手工构造半字节断: 在 cut 事务后只追加 2 个数据位(SCL 上升沿采样) +tail_t, _tail_v = edges[-1] +t = tail_t + 10 +tail = [ + (t, 1), # SDA 升(SCL 低) + (t + 1, 3), # SCL 升 -> 采样到 1 + (t + 4, 1), # SCL 降 + (t + 5, 3), # SCL 升 -> 采样到 1 (只到第 2 位) +] +got = d.decode_edges(edges + tail) +check('partial ended', got[0].ended, 'partial') + +# 4) 随机边沿流不崩溃(内容不确定, 只要不抛异常且时间戳单调可行) +rng = random.Random(20260930) +for trial in range(50): + n = rng.randrange(1, 4000) + t = 0 + rnd = [] + for _ in range(n): + t += rng.randrange(1, 50) + rnd.append((t, rng.randrange(0, 4))) + try: + d.decode_edges(rnd) + except Exception as e: # noqa: BLE001 + fails.append(f'random-trial-{trial}') + print('FAIL', f'random-trial-{trial}', '->', repr(e)) + break +else: + print('PASS 随机边沿流 50 组无异常') + +# 5) T 行 to/parse 往返(各 ended + 空/多字节) +tline_fam = [ + T(t0_us=123456, t1_us=123543, addr7=0x32, read=False, + acks=(True, True, True), data=bytes([0xFF, 0x60]), ended='stop'), + T(t0_us=999, t1_us=1234, addr7=0x50, read=True, + acks=(True, False), data=bytes([0xAB]), ended='stop'), + T(t0_us=5, t1_us=99, addr7=0x2A, read=False, acks=(True, True), + data=b'\x01', ended='rstart'), + T(t0_us=7, t1_us=70, addr7=0x68, read=True, acks=(True,), data=b'', + ended='cut'), + T(t0_us=8, t1_us=80, addr7=0x08, read=False, acks=(False,), data=b'', + ended='partial'), + T(t0_us=9, t1_us=900, addr7=0x00, read=False, acks=(True,) * 17, + data=bytes(range(16)), ended='stop'), +] +for i, tr in enumerate(tline_fam): + line = d.to_tline(tr) + back = d.parse_tline(line) + check(f'tline[{i}] key', back.key(), tr.key()) + check(f'tline[{i}] t0/dur', (back.t0_us, back.dur_us), + (tr.t0_us, tr.dur_us)) + check(f'tline[{i}] truncated', back.truncated, tr.truncated) + +# 截断标志 'L' 往返(固件长事务只保留前 56 字节) +trunc = T(t0_us=42, t1_us=99, addr7=0x32, read=False, acks=(True,) * 4, + data=bytes([0x11, 0x22, 0x33]), ended='stop', truncated=True) +check('tline trunc flags', d.to_tline(trunc), + 'T 42 57 32 W -L AAAA 3 11 22 33') +check('tline trunc roundtrip', d.parse_tline(d.to_tline(trunc)).truncated, True) +try: + d.parse_tline('T 1 2 33 W rLL A 0') + fails.append('tline-flags-len-accepted') + print('FAIL tline-flags-len -> 被接受') +except ValueError: + print('PASS tline-flags-len -> 拒绝') + +check('tline sample', d.to_tline(tline_fam[0]), + 'T 123456 87 32 W - AAA 2 FF 60') +check('tline addr-only', d.to_tline(tline_fam[4]), 'T 8 72 08 W p N 0') + +# 非法 T 行被拒 +for bad in ('X 1 2 3 W - A 0', 'T 1 2 3 W - A 2 11', + 'T 1 2 3 Q - A 0', 'T 1 2 3 W z A 0', 'T 1 2 3 W - AX 0', + 'T 1 2 3 W - A -1'): + try: + d.parse_tline(bad) + fails.append(f'tline-bad-accepted:{bad!r}') + print('FAIL', f'tline-bad:{bad!r}', '-> 被接受') + except ValueError: + print('PASS', f'tline-bad:{bad!r}', '-> 拒绝') + +# 6) W 行解析 +check('wline', d.parse_wline('W 1234 3'), (1234, 3)) +check('wline end', d.parse_wline('W.'), None) +try: + d.parse_wline('W 12 7') + fails.append('wline-bad-accepted') + print('FAIL wline-bad -> 被接受') +except ValueError: + print('PASS wline-bad -> 拒绝') + +# 7) CSV 导出形状 +csv = d.transactions_to_csv(tline_fam).splitlines() +check('csv header', csv[0], + 'idx,t0_us,dur_us,addr7,rw,addr_ack,data_hex,data_acks,ended') +check('csv rows', len(csv), 1 + len(tline_fam)) +check('csv row0', csv[1], '0,123456,87,32,W,A,FF 60,AA,stop') + +# 8) 显示行格式(format 输出含地址/方向/数据/ACK) +edges, _ = d.encode_edges(fam) +tr = d.decode_edges(edges)[0] +line = tr.format(idx=0) +check_true('format 含地址与方向', + ' 0x32 W: FF 60' in line and 'ACK:AAA' in line, line) +check_true('format 含时间戳', 't=' in line and 'us' in line, line) + +print() +if fails: + print(f'共 {len(fails)} 项失败:', ', '.join(fails)) + sys.exit(1) +print('全部通过') diff --git a/I2C-Lab/src/selftest_gui.py b/I2C-Lab/src/selftest_gui.py new file mode 100644 index 0000000..ac2f1f5 --- /dev/null +++ b/I2C-Lab/src/selftest_gui.py @@ -0,0 +1,151 @@ +"""GUI 功能自检(offscreen 无窗口): 事务入表/地址过滤/暂停滚动/波形喂数/ +CSV 导出池/固件统计行/配置往返 +==================================================================== +不依赖硬件与串口, 直接驱动 Window 的事件处理函数; 改 main.py 后建议重跑。 + +用法: python selftest_gui.py (全部通过退出码 0) +""" +import os +import sys + +os.environ['QT_QPA_PLATFORM'] = 'offscreen' +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from PySide6.QtWidgets import QApplication + +import i2cdec +import main as m +from i2cdec import Transaction + +fails = [] + + +def check(name, got, want): + ok = got == want + print(('PASS' if ok else 'FAIL'), name, '->', repr(got)[:120], + '' if ok else f'(期望 {want!r})') + if not ok: + fails.append(name) + + +app = QApplication(sys.argv) +win = m.Window() +win.show() + +T = Transaction + +# 1) 事务入表 + 全量池 +tr1 = T(t0_us=100, t1_us=200, addr7=0x32, read=False, + acks=(True, True), data=b'\xff\x60', ended='stop') +tr2 = T(t0_us=900, t1_us=1100, addr7=0x50, read=True, + acks=(True, False), data=b'\x12', ended='stop') +win._paused = False +for tr in (tr1, tr2): + win._on_trans(tr) +check('表格行数', win.table.rowCount(), 2) +check('导出池数量', len(win.transactions), 2) +check('事务计数', win._trans_total, 2) +check('地址列', win.table.item(0, 3).text(), '0x32') +check('RW 列', win.table.item(1, 4).text(), 'R') +check('ACK 列', win.table.item(0, 5).text(), 'AA') +check('数据列', win.table.item(0, 6).text(), 'FF 60') +check('收尾列', win.table.item(0, 7).text(), 'STOP') + +# 2) 地址过滤: 只留 0x32; 非法过滤项被忽略 +win.ed_filter.setText('32 zz') +win._on_trans(tr2) +check('过滤生效(0x50 不入表)', win.table.rowCount(), 2) +check('计数仍累计', win._trans_total, 3) +win.ed_filter.setText('') +win._on_trans(tr2) +check('清过滤后入表', win.table.rowCount(), 3) + +# 3) 地址 NAK 标红 / 未收尾标橙(前景色判断) +tr_nak = T(t0_us=1, t1_us=2, addr7=0x10, read=False, acks=(False,), + data=b'', ended='stop') +tr_cut = T(t0_us=3, t1_us=9, addr7=0x11, read=False, acks=(True,), + data=b'', ended='cut') +win._on_trans(tr_nak) +win._on_trans(tr_cut) +r_nak = win.table.rowCount() - 2 +check('地址 NAK 前景红色', + win.table.item(r_nak, 5).foreground().color().name(), '#e0564c') +check('cut 收尾前景橙色', + win.table.item(r_nak + 1, 7).foreground().color().name(), '#e08a2e') + +# 4) 长事务截断显示 + tooltip 全量 +tr_long = T(t0_us=1, t1_us=2, addr7=0x2a, read=False, acks=(True,) * 20, + data=bytes(range(20)), ended='stop') +win._on_trans(tr_long) +r = win.table.rowCount() - 1 +check('长数据显示截断省略', win.table.item(r, 6).text().endswith('…'), True) +check('长数据 tooltip 全量', + win.table.item(r, 6).toolTip() == tr_long.data_hex(), True) + +# 5) 暂停滚动: 不再 scrollToBottom, 但行照加 +win._toggle_pause() +check('暂停按钮文案', win.btn_pause.text(), '继续滚动') +win._on_trans(tr1) +check('暂停时行照加', win.table.rowCount() >= 6, True) +win._toggle_pause() + +# 6) 表格行数上限: 批量灌 5100 条, 顶行被裁 +win._clear_table() +for i in range(5100): + win._on_trans(T(t0_us=i, t1_us=i + 1, addr7=0x01, read=False, + acks=(True,), data=b'', ended='stop')) +check('表格行数封顶', win.table.rowCount(), m.TABLE_CAP) +check('导出池保留全部( 0, True) +win._toggle_demo() +check('演示停止', not win._demo_timer.isActive(), True) + +win.close() +app.quit() + +print() +if fails: + print(f'共 {len(fails)} 项失败:', ', '.join(fails)) + sys.exit(1) +print('全部通过') diff --git a/I2C-Lab/src/theme.py b/I2C-Lab/src/theme.py new file mode 100644 index 0000000..f8f97c2 --- /dev/null +++ b/I2C-Lab/src/theme.py @@ -0,0 +1,132 @@ +"""Theme persistence and Fluent Widgets integration. + +与 DLPilot/SerialMate 的 theme.py 同源(三件套: setTheme + QPalette/QSS ++ DWM 标题栏), 仅改应用名。""" + +import ctypes +import os + +from PySide6.QtCore import QObject, QSettings, Qt +from PySide6.QtGui import QColor, QPalette +from qfluentwidgets import Theme, setTheme, setThemeColor + +# Fluent widgets restyle themselves on setTheme(); QMainWindow, the native +# QTabWidget/QStatusBar and the Windows title bar keep the OS palette unless +# the palette, an app stylesheet and the DWM title bar attributes follow suit +# (same approach as DLPilot app_fluent.py). +_SURFACES = { + False: {"window": "#f4f6f8", "base": "#ffffff", "text": "#1f2328", + "dim": "#57606a", "border": "#d8dee4", "selected_text": "#ffffff"}, + True: {"window": "#202020", "base": "#2b2b2b", "text": "#f5f5f5", + "dim": "#9a9a9a", "border": "#3a3a3a", "selected_text": "#1f2328"}, +} + + +class ThemeManager(QObject): + """Keep the appearance choice persistent and synchronized with Qt.""" + + MODES = {"system", "light", "dark", "dark1", "dark2"} + _THEMES = { + "system": Theme.AUTO, + "light": Theme.LIGHT, + "dark": Theme.DARK, + "dark1": Theme.DARK, + "dark2": Theme.DARK, + } + _ACCENTS = { + "light": "#0f6cbd", + "dark": "#f2c94c", + "dark1": "#5aa9e6", + "dark2": "#35c6c8", + } + + def __init__(self, app): + super().__init__(app) + self.app = app + self.window = None + self.settings = QSettings("LT8619C-Tools", "I2C-Lab") + mode = self.settings.value("appearance/theme", "system") + self.mode = mode if mode in self.MODES else "system" + self.apply() + app.styleHints().colorSchemeChanged.connect(self.apply) + + def set_window(self, window): + """Register the main window so the native title bar follows the theme.""" + self.window = window + self._apply_titlebar() + + def set_mode(self, mode): + if mode not in self.MODES: + raise ValueError(f"Unknown theme: {mode}") + self.mode = mode + self.settings.setValue("appearance/theme", mode) + self.apply() + + def is_dark(self): + if self.mode in {"dark", "dark1", "dark2"}: + return True + return self.mode == "system" and self.app.styleHints().colorScheme() == Qt.ColorScheme.Dark + + def apply(self, *_): + is_dark = self.is_dark() + setTheme(self._THEMES[self.mode]) + setThemeColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"])) + self._apply_palette(is_dark) + self._apply_titlebar() + + def _apply_palette(self, is_dark): + """Give native Qt surfaces the same colors as the Fluent widgets.""" + surface = _SURFACES[is_dark] + accent = QColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"])) + palette = QPalette() + for role, color in ( + (QPalette.Window, surface["window"]), + (QPalette.Base, surface["base"]), + (QPalette.AlternateBase, surface["base"]), + (QPalette.Text, surface["text"]), + (QPalette.WindowText, surface["text"]), + (QPalette.Button, surface["base"]), + (QPalette.ButtonText, surface["text"]), + (QPalette.PlaceholderText, surface["dim"]), + (QPalette.Highlight, accent), + (QPalette.HighlightedText, surface["selected_text"]), + (QPalette.ToolTipBase, surface["base"]), + (QPalette.ToolTipText, surface["text"]), + ): + palette.setColor(role, QColor(color)) + self.app.setPalette(palette) + self.app.setStyleSheet(f""" + QMainWindow, QWidget#root {{ background-color: {surface['window']}; color: {surface['text']}; }} + QTabWidget::pane {{ background: {surface['base']}; border: 1px solid {surface['border']}; border-radius: 6px; }} + QTabBar {{ qproperty-drawBase: 0; }} + QTabBar::tab {{ background: transparent; color: {surface['dim']}; padding: 6px 16px; + border: 1px solid transparent; border-top-left-radius: 6px; + border-top-right-radius: 6px; }} + QTabBar::tab:selected {{ background: {surface['base']}; color: {surface['text']}; + border: 1px solid {surface['border']}; }} + QTabBar::tab:hover:!selected {{ background: {surface['base']}; }} + QSplitter::handle {{ background: {surface['window']}; }} + QStatusBar {{ background: {surface['window']}; color: {surface['dim']}; + border-top: 1px solid {surface['border']}; }} + QToolTip {{ background-color: {surface['base']}; color: {surface['text']}; + border: 1px solid {surface['border']}; }} + """) + + def _apply_titlebar(self): + """Ask DWM for a title bar in the current surface color (Win10/11).""" + if self.window is None or os.name != "nt" or not hasattr(ctypes, "windll"): + return + try: + hwnd = int(self.window.winId()) + dwm = ctypes.windll.dwmapi + dark = ctypes.c_int(1 if self.is_dark() else 0) + dwm.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark)) + surface = _SURFACES[self.is_dark()] + dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(self._colorref(QColor(surface["window"]))), 4) + dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(self._colorref(QColor(surface["text"]))), 4) + except Exception: + pass + + @staticmethod + def _colorref(color): + return ctypes.c_int(color.red() | (color.green() << 8) | (color.blue() << 16)) diff --git a/I2C-Lab/src/waveview.py b/I2C-Lab/src/waveview.py new file mode 100644 index 0000000..cd26056 --- /dev/null +++ b/I2C-Lab/src/waveview.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +waveview.py — I2C 波形视图(逻辑分析仪风格, 纯 QPainter 绘制) +============================================================== +输入: 边沿快照 [(t_us, pins)..] (bit0=SDA bit1=SCL), 自动叠加 +i2cdec.decode_edges 的事务注记(START/STOP 刻线 + 事务区间标签)。 + +交互: 滚轮缩放(以光标为锚), 左键拖动平移, 双击适配全宽, + Ctrl+滚轮 8 倍步进; 空数据时画占位提示。 +主题: 取当前 QPalette 判明暗, 线色固定(SCL 青/SDA 橙, 双主题可辨)。 +""" + +from PySide6.QtCore import QPoint, Qt +from PySide6.QtGui import QColor, QFont, QMouseEvent, QPainter, QPalette, QPen +from PySide6.QtWidgets import QWidget + +import i2cdec + +COL_SCL = QColor('#39b8e0') +COL_SDA = QColor('#f2952e') +COL_MARK_START = QColor('#4caf50') +COL_MARK_STOP = QColor('#e0564c') + +TOP_PAD = 18 # 顶部标签带 +LANE_H = 46 # 每条线高度 +LANE_GAP = 18 +RULER_H = 22 # 底部时间刻度 +MIN_US_PER_PX = 0.001 +MAX_US_PER_PX = 5000.0 + + +class WaveView(QWidget): + """SDA/SCL 双线时序图 + 事务注记; set_edges() 喂数据""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(TOP_PAD + 2 * LANE_H + LANE_GAP + RULER_H + 8) + self.setMouseTracking(True) + self._edges = [] # [(t_us, pins)] + self._trans = [] # 注记事务 + self._us_per_px = 5.0 + self._t_left = 0.0 # 视窗左端时间(us) + self._hover_t = None + self._drag = None + self._font = QFont(self.font().family(), 8) + + # ---------- 数据 ---------- + + def set_edges(self, edges): + self._edges = list(edges) + try: + self._trans = i2cdec.decode_edges(self._edges) + except Exception: + self._trans = [] + self.fit_all() + self.update() + + def has_data(self): + return len(self._edges) >= 2 + + # ---------- 视窗 ---------- + + def t_span(self): + if not self._edges: + return 0.0 + return self._edges[-1][0] - self._edges[0][0] + + def fit_all(self): + w = max(self.width() - 2, 1) + span = self.t_span() + self._us_per_px = max(min(span / w, MAX_US_PER_PX), MIN_US_PER_PX) + self._t_left = self._edges[0][0] if self._edges else 0.0 + + def _x(self, t): + return (t - self._t_left) / self._us_per_px + + def _t(self, x): + return self._t_left + x * self._us_per_px + + def _time_base(self): + return self._edges[0][0] if self._edges else 0 + + # ---------- 事件 ---------- + + def mousePressEvent(self, ev: QMouseEvent): + if ev.button() == Qt.LeftButton: + self._drag = (ev.position().x(), self._t_left) + + def mouseMoveEvent(self, ev: QMouseEvent): + x = ev.position().x() + if self._drag is not None: + self._t_left = self._drag[1] - (x - self._drag[0]) * self._us_per_px + self.update() + self._hover_t = self._t(x) + self.update() + + def mouseReleaseEvent(self, ev): + if ev.button() == Qt.LeftButton: + self._drag = None + + def leaveEvent(self, ev): + self._hover_t = None + self.update() + + def wheelEvent(self, ev): + factor = 1.25 if ev.angleDelta().y() > 0 else 0.8 + if ev.modifiers() & Qt.ControlModifier: + factor = factor ** 3 + anchor_t = self._hover_t if self._hover_t is not None \ + else self._t(self.width() / 2) + self._us_per_px = max(min(self._us_per_px / factor, MAX_US_PER_PX), + MIN_US_PER_PX) + self._t_left = anchor_t - (anchor_t - self._t_left) + # 以光标为锚: 保持 anchor 在原像素位置 + if self._hover_t is not None: + self._t_left = anchor_t - ev.position().x() * self._us_per_px + self.update() + + def mouseDoubleClickEvent(self, ev): + self.fit_all() + self.update() + + def resizeEvent(self, ev): + super().resizeEvent(ev) + + # ---------- 绘制 ---------- + + def paintEvent(self, _ev): + p = QPainter(self) + try: + self._paint(p) + finally: + p.end() # 异常路径也必须收笔, 否则画布状态损坏 + + def _paint(self, p): + p.setFont(self._font) + w, h = self.width(), self.height() + pal = self.palette() + base = pal.color(QPalette.ColorRole.Base) + text = pal.color(QPalette.ColorRole.Text) + dim = QColor(text) + dim.setAlpha(150) + p.fillRect(self.rect(), base) + + lane_y = {'SCL': TOP_PAD, 'SDA': TOP_PAD + LANE_H + LANE_GAP} + for name, color in (('SCL', COL_SCL), ('SDA', COL_SDA)): + y = lane_y[name] + p.setPen(QPen(dim, 1, Qt.DashLine)) + p.drawLine(0, y + LANE_H, w, y + LANE_H) # 低电平基线 + p.setPen(QPen(color)) + p.drawText(4, y + 12, name) + + if not self.has_data(): + p.setPen(QPen(dim)) + p.drawText(self.rect(), Qt.AlignCenter, + '无边沿数据 — 连接固件后点「抓取波形」') + return + + t0_view, t1_view = self._t(0), self._t(w) + edges = self._edges + # 视窗外裁剪: 二分找起点 + import bisect + ts = [e[0] for e in edges] + i0 = max(bisect.bisect_left(ts, t0_view) - 1, 0) + # 电平阶梯线(两线一起走) + pens = {'SCL': QPen(COL_SCL, 2), 'SDA': QPen(COL_SDA, 2)} + last = {'SCL': None, 'SDA': None} + for i in range(i0, len(edges)): + t, pins = edges[i] + if t > t1_view: + break + scl, sda = (pins >> 1) & 1, pins & 1 + x = self._x(t) + for name, lv in (('SCL', scl), ('SDA', sda)): + y_lo = lane_y[name] + LANE_H + y_hi = lane_y[name] + 4 + y = y_lo if lv == 0 else y_hi + if last[name] is not None: + py, px = last[name] + p.setPen(pens[name]) + if py != y: # 前一电平延伸到跳变点 + p.drawLine(QPoint(int(px), int(py)), QPoint(int(x), int(py))) + p.drawLine(QPoint(int(x), int(py)), QPoint(int(x), int(y))) + last[name] = (y, x) + for name in ('SCL', 'SDA'): # 末段延伸到右缘 + if last[name] is not None: + py, px = last[name] + p.setPen(pens[name]) + p.drawLine(QPoint(int(px), int(py)), QPoint(w, int(py))) + + # 事务注记: START/STOP 刻线 + 事务标签(顶部) + p.setPen(QPen(dim, 1)) + for tr in self._trans: + xs = self._x(tr.t0_us) + xe = self._x(tr.t1_us) + if xe < 0 or xs > w: + continue + p.setPen(QPen(COL_MARK_START, 1, Qt.DashLine)) + p.drawLine(QPoint(int(xs), TOP_PAD - 4), QPoint(int(xs), h - RULER_H)) + p.setPen(QPen(COL_MARK_STOP, 1, Qt.DashLine)) + p.drawLine(QPoint(int(xe), TOP_PAD - 4), QPoint(int(xe), h - RULER_H)) + label = f'{tr.addr7:02X}{tr.rw_char()} {tr.data_hex(6)}' + p.setPen(QPen(text)) + if xe - xs > p.fontMetrics().horizontalAdvance(label) + 8: + p.drawText(int(xs) + 3, TOP_PAD - 6, label) + elif 0 <= xs <= w - 30: + p.drawText(int(xs) + 3, TOP_PAD - 6, f'{tr.addr7:02X}') + + # 时间刻度(底部): 取"好看"的步长 + p.setPen(QPen(dim, 1)) + p.drawLine(0, h - RULER_H + 4, w, h - RULER_H + 4) + nice = self._nice_step(self._us_per_px * 90) + t = (int(t0_view // nice) + 1) * nice + base_t = self._time_base() + while t <= t1_view: + x = int(self._x(t)) + p.drawLine(x, h - RULER_H + 4, x, h - RULER_H + 10) + p.drawText(x + 2, h - RULER_H + 18, self._fmt_us(t - base_t)) + t += nice + + # 光标 + if self._hover_t is not None and 0 <= self._hover_t <= t1_view: + x = int(self._x(self._hover_t)) + p.setPen(QPen(text.darker(120), 1, Qt.DotLine)) + p.drawLine(x, TOP_PAD - 6, x, h - RULER_H + 4) + p.setPen(QPen(text)) + p.drawText(min(x + 4, w - 70), TOP_PAD + 10, + self._fmt_us(self._hover_t - base_t)) + + # ---------- 工具 ---------- + + @staticmethod + def _nice_step(raw): + steps = (1, 2, 5) + mag = 1.0 + while mag < raw: + for s in steps: + if s * mag >= raw: + return s * mag + mag *= 10 + return mag + + @staticmethod + def _fmt_us(v): + v = abs(v) + if v >= 1e6: + return f'{v / 1e6:.3f}s' + if v >= 1000: + return f'{v / 1000:.2f}ms' + return f'{v:.0f}us' diff --git a/README.md b/README.md index ac0aeb6..18809fe 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ | DLPilot 图标 | [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases) 下 `DLPilot-vX.Y.Z-win64.zip`,解压即用;详见 [DLPilot/README.md](DLPilot/README.md) | | EDID-Viewer 图标 | [EDID-viewer](EDID-viewer/) | Windows 显示器 EDID 只读查看器:活动设备或离线文件的厂家/序列号/分辨率时序、CTA/HDMI/音频/HDR 能力、校验诊断;BIN/HEX 导入,BIN/JSON 报告导出 | 到 [Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases) 下 `EDID-Viewer-vX.Y.Z-win64.zip`,解压即用;详见 [EDID-viewer/README.md](EDID-viewer/README.md) | | SerialMate 图标 | [SerialMate](SerialMate/) | RS485/串口调试助手:收发 + 协议包解析(帧构成令牌序列/包头/板ID/LEN口径/CRC 全可定制,默认兼容 DLPilot 光机协议)、板型档案一键切换协议参数与命令预设、payload 字符串/HEX 显示、4 通道曲线(弹窗独立、时间窗滑笔回看、悬停读值、暂停渲染)、发送框输入 payload 自动包装组帧(`/` 分隔多条)、HEX 直发与周期发送 | 到 [Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases) 下 `SerialMate-vX.Y.Z-win64.zip`,解压即用;详见 [SerialMate/README.md](SerialMate/README.md) | -| I2C-Inject 图标 | [I2C-Inject](I2C-Inject/) | ⏸ **暂停开发**:LT8619C 寄存器在线读写(RP2040 注入器在 demo 板 I2C 总线上择机注入,避让主控轮询,写后自动读回校验)。代码与固件保留备查 | 暂不提供发布;状态与原理详见 [I2C-Inject/README.md](I2C-Inject/README.md) | +| I2C-Lab 图标 | [I2C-Lab](I2C-Lab/) | I2C 总线监控调试台:RP2040 probe 固件纯被动监听(PIO 硬件捕获 + 实时解码,不加上拉不驱动),PC 端实时事务流(地址/RW/ACK/数据/收尾)、地址过滤、CSV 导出、逻辑分析仪式波形快照(滚轮缩放/事务注记);主机读写与从机模拟在路线图上(前身 I2C-Inject 的注入职能将并入) | 到 [Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases) 下 `I2C-Lab-vX.Y.Z-win64.zip`(含 probe 固件 UF2),解压即用;详见 [I2C-Lab/README.md](I2C-Lab/README.md) | +| I2C-Inject 图标 | [I2C-Inject](I2C-Inject/) | ⏸ **暂停开发(留档备查)**:LT8619C 寄存器在线读写(RP2040 注入器在 demo 板 I2C 总线上择机注入,避让主控轮询,写后自动读回校验)。后续开发并入 I2C-Lab(监控已就绪,注入将在其主机阶段复活) | 暂不提供发布;状态与原理详见 [I2C-Inject/README.md](I2C-Inject/README.md) | | GD32 固件工具图标 | [gd32e230f8_firmware_merge_tool](gd32e230f8_firmware_merge_tool/) | 🔗 **子模块**(独立仓库独立管理):GD32E230 固件工具——APP/BL 合成补齐(`_UPDATE.bin`/`_BL.bin`)+ DAPLink Flash GUI 烧录(自带 OpenOCD 工具链) | 本仓目录即快捷入口;版本更新与发布在其[原仓库](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool)(Releases 下 zip 解压即用);详见 [README](gd32e230f8_firmware_merge_tool/README.md) | ## 环境要求 @@ -21,9 +22,11 @@ `py -m pip install PySide6-Fluent-Widgets`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial); EDID-viewer GUI 需 `py -m pip install -r EDID-viewer/requirements.txt`(PySide6 + PySide6-Fluent-Widgets); SerialMate GUI 需 `py -m pip install PySide6 PySide6-Fluent-Widgets pyserial`; - I2C-Inject 需 `py -m pip install pyserial` + I2C-Lab GUI 同上;重新构建 probe 固件需 pico-sdk 2.x + Arm GNU 交叉工具链 + (见 I2C-Lab/AGENTS.md) - 重新打包:`DLPilot/src/build_exe.py`、`EDID-viewer/src/build_exe.py` 与 - `SerialMate/src/build_exe.py`(均自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py` + `SerialMate/src/build_exe.py`、`I2C-Lab/src/build_exe.py`(均自动安装 PyInstaller); + I2C-Inject 目录内 `python build_exe.py` ## 目录结构 @@ -50,22 +53,31 @@ HW-Debug-Tools/ │ ├── release/ # SerialMate.exe + README(入库);发布 zip(不入库) │ ├── README.md # 使用说明 │ └── AGENTS.md # 工程档案 -├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README +├── I2C-Lab/ +│ ├── src/ # 解码/固件链路/波形控件/GUI + 自检 + 打包/发布脚本 +│ ├── firmware/ # probe 固件(pico-sdk C + PIO + make_uf2.py) +│ ├── assets/ # 图标(I2C 橙色母题) +│ ├── release/ # I2C-Lab.exe + firmware/i2c_lab.uf2(交付物本地保留,不入库;发布时走流水线) +│ ├── README.md # 使用说明 +│ └── AGENTS.md # 工程档案 +├── I2C-Inject/ # ⏸ 暂停开发留档(PC GUI + MicroPython 固件 + 打包 exe + 图标 + README) ├── gd32e230f8_firmware_merge_tool/ # 🔗 git 子模块 → GD32E230 固件工具(独立仓库,见下方备注) +├── icons/ # 图标家族唯一权威:风格与内容约定(README.md)+ 统一生成器(make_icon.py) ├── LICENSE └── README.md ``` -## 发布流程(DLPilot / EDID-Viewer / SerialMate 同一套流水线) +## 发布流程(DLPilot / EDID-Viewer / SerialMate / I2C-Lab 同一套流水线) ```powershell -cd DLPilot/src # 或 EDID-viewer/src、SerialMate/src +cd DLPilot/src # 或 EDID-viewer/src、SerialMate/src、I2C-Lab/src python build_exe.py # 打包 → ../release/<工具>.exe python package_release.py <版本> # → ../release/<工具>-v<版本>-win64.zip python publish_gitea.py <工具>-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) ``` -每个工具独立打标签发布(`DLPilot-v*` / `EDID-Viewer-v*` / `SerialMate-v*`),Release 列表按时间倒序混排于同一页面。 +每个工具独立打标签发布(`DLPilot-v*` / `EDID-Viewer-v*` / `SerialMate-v*` / +`I2C-Lab-v*`),Release 列表按时间倒序混排于同一页面。 ## 备注 @@ -76,4 +88,6 @@ python publish_gitea.py <工具>-v<版本>-win64.zip --insecure # 上传 Gite 跟进其最新版用 `git submodule update --remote gd32e230f8_firmware_merge_tool`,随本仓提交新指针 - 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪 双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/` -- 更新:2026-09-30 +- 全部工具图标(logo 家族:黑底 + 工具色圆角块 + 两行字)的约定与再生成见 + [icons/README.md](icons/README.md),统一生成器 `icons/make_icon.py` +- 更新:2026-10-10 diff --git a/agent.md b/agent.md index 4d7e5e0..ba654a7 100644 --- a/agent.md +++ b/agent.md @@ -27,8 +27,14 @@ - **SerialMate**:RS485/串口调试助手,帧格式(包头/板ID/LEN/CRC)可定制, 默认兼容 DLPilot 光机协议;payload 字符串/HEX 显示、4 通道曲线、payload 自动包装发送。 + - **I2C-Lab**(2026-10-02 新启,即 §3.5 三合一需求落地):I2C 总线监控调试台。 + 阶段一=纯被动监控:RP2040 probe 固件(pico-sdk C,PIO 边沿捕获 + 核1 实时 + 解码 + USB CDC `=`/`!` 命令行,250MHz)+ PC GUI(Fluent,事务流/地址过滤/ + CSV/逻辑分析仪式波形快照/演示模式)。主机读写(复活 I2C-Inject 择机注入) + 与从机模拟在路线图。解码语义权威 `I2C-Lab/src/i2cdec.py`。 - **I2C-Inject**:RP2040 固件 + PC GUI,demo 板 I2C 总线择机注入读写寄存器。 - **2026-09-25 起暂停开发**,代码与固件保留备查。 + **2026-09-25 起暂停开发**,代码与固件保留备查;**2026-10-02 起后续开发并入 + I2C-Lab**(接线/回显约定与解码算法已继承过去)。 - 子模块引用:`gd32e230f8_firmware_merge_tool/`(2026-09-30 接入,git submodule 指向 `hulk/gd32e230f8_firmware_merge_tool`)——GD32E230 APP/BL 合成补齐 (`_UPDATE.bin`/`_BL.bin`)+ DAPLink Flash GUI 烧录。代码、版本与发布均在原仓库 @@ -37,6 +43,12 @@ - 镜像约定:`EDID-viewer` 主目录(`E:\Hulk-Coding\HW-Debug-Tools\EDID-viewer`) 改动后需 robocopy /MIR 同步到 `E:\Hulk-Coding\LT8619C\_Debug\_Tools\EDID-viewer` (排除 .venv/build/__pycache__/.git,zip 不同步)。 +- 图标约定(2026-10-10 立):全部工具共用 **logo 家族版式**(512 黑底 + 工具色圆角块 + + 上行域词黑字/下行角色词白字,Arial Bold 146px 基准自适应;颜色不参与家族识别, + 现分配橙 `#FF9900`×4、绿 `#00C878`×2、青 `#00C8D7`×1,紫 `#A78BFA` 暂未分配);软件内按钮只用 + FluentIcon 白名单、文本内用单色 Unicode 符号(→ ← ⚠ ⏳ ⚙)。**唯一权威 `icons/`**: + 约定文档 `icons/README.md` + 统一生成器 `icons/make_icon.py`(含 `--check` + 复现校验与 `--preview` 家族预览图;各工具内旧 make_ico 脚本保留作历史对照,不再维护)。 - 发布约定:源码与打包产物(exe、运行配置)入库;发布 zip 不入库,只上传 Gitea Release。 ## 2. 开发进度(已完成功能清单) @@ -73,8 +85,8 @@ ### 2.3 SerialMate — v0.1.0(2026-09-27,首版) - **更名**:原名 SerialAssistant(目录/exe/文档/配置同步更名;旧配置 - serial_assistant.json 首次启动自动迁移为 serial_mate.json;图标与名字 - 无绑定为波形+RS485 母题)。用户确认 "SerialMate 吧"(2026-09-27)。 + serial_assistant.json 首次启动自动迁移为 serial_mate.json;更名时的图标为 + 波形+RS485 母题,后统一为家族版式,见 §2.6)。用户确认 "SerialMate 吧"(2026-09-27)。 - **已发布**:commit d133906 推送 main 并打 tag `SerialMate-v0.1.0`, Release 附件 `SerialMate-v0.1.0-win64.zip`(63.7 MB)已上传核对。 - 协议:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` + 十六进制字面量 @@ -106,13 +118,57 @@ - 已完成:RP2040 注入器固件(MicroPython,内置 viper 总线嗅探)、PC 端 GUI、 demo 板寄存器语义实测(0x606E bit5=镜像 D[7:0]、0x606D 整组顺序,逻辑分析仪 双色交叉验证)。 -- 状态:暂停开发,代码与固件保留备查,README 顶部有状态横幅。 +- 状态:暂停开发,代码与固件保留备查,README 顶部有状态横幅;后续开发并入 + I2C-Lab(2026-10-02,见 §2.5/§3.5)。 + +### 2.5 I2C-Lab — v0.1.0 已就绪(2026-10-02,待真机验收后发布) +- **定位**:I2C 总线监控调试台(原 §3.5 三合一需求的监控阶段);三区目录 + + 双文档对齐全仓约定;I2C-Inject 定位为留档,本工具独立续接。 +- **固件**(`firmware/`,pico-sdk C,PICO_BOARD=pico):单 PIO SM `bus_watch` + (变沿即压 FIFO,push noblock,~40ns 分辨率)+ 核1 时间戳/解码状态机(语义 + 与 i2cdec.py 一致,含 56 字节长事务截断)+ 核0 USB CDC CLI(?/v/mon/ + stats/wave/boot);250MHz/vreg 1.15V;边沿环 16k(wave 快照,暂停换一致性) + + 事务环 256。 +- **PC 端**(`src/`):i2cdec.py(边沿→事务/T·W 行语法/CSV/参考编码器,自测 + 73 项全绿:100k/400k 往返+时间戳对齐、随机鲁棒、截断 L 标志)、probelink.py + (行协议链路线程)、waveview.py(QPainter 波形:滚轮缩放/拖动/双击适配/ + START-STOP 刻线/事务标签)、main.py(Fluent 双栏:串口卡/监控卡/事务表/ + 波形卡/固件日志 + 演示模式)、theme/config 同源;selftest_gui.py 29 项全绿; + build/package/publish 三件套适配(发布 zip 含 UF2)。 +- **构建链(本机打通并记录踩坑)**:pico-sdk 2.3.1(ghproxy 代理克隆)+ + tinyusb 0.18.0(gitee 镜像)+ Arm GNU 13.3;pioasm 宿主编译用 ziglang + (pip 装,预置 CMakeCache 分号列表法);UF2 用自制 make_uf2.py(objcopy→bin + →UF2,往返自检 + 与真机 MicroPython UF2 逐字段核对一致),绕开 picotool + 的 github 依赖。 +- **产物**:I2C-Lab.exe(63.8MB)+ firmware/i2c_lab.uf2/.bin 保留本地 + release/(交付物不入库,`.gitignore` 已挡,2026-10-13 起); + zip/Release 待真机验收后走流水线(tag `I2C-Lab-v0.1.0`)。 + +### 2.6 仓库级基础设施 — 图标家族约定与统一生成器(2026-10-10) +- **`icons/` 目录**:`README.md`(风格与内容约定 + 应用点总表 + 新工具接入清单) + + `make_icon.py`(六工具统一生成器,吸收 DLPilot/EDID-viewer/SerialMate/ + I2C-Inject/FlashTool 五份分散脚本)+ `preview.png` 家族预览图。 +- **复现验证**:`--check` 逐像素比对,DLPilot/EDID-viewer/SerialMate/I2C-Inject + 全 SAME(EDID 的 134/116px 历史版式以显式 override 保真)。 +- **修正 I2C-Lab 图标**:第二行自 I2C-Inject 复制 SVG 后一直误为 "Inject", + 已改 "Lab"(png/ico/svg 重生成;exe 为入库旧图,下次 build_exe 打包生效)。 +- **发现待办(子模块内)**:FlashTool 图标 "FLASH" 146px 宽 485px 超出 436px + 色块(两端黑字落黑底不可见),家族自适应规则可修正,但需在其原仓库执行并提交。 +- **FilmForge 图标(2026-10-10)**:热压模具温控监视台(独立仓 + `E:\Hulk-Coding\FilmForge`,GD32F4 固件 + GUI 上位机)按家族版式出图—— + 上行 `FILM`/下行 `Forge`、青 `#00C8D7`(取自其温度曲线/主题青,按钮 `#29F1FF` + 太亮故取深一档;与其 Dark 曲线配色常量一致),产物落在其 + `GUI/logos/filmforge.{png,ico,svg}`(`build_exe.py:30` 预留路径); + 并在其 `GUI/app_fluent.py` 补上运行时 `setWindowIcon`(_MEIPASS/_源码双路, + 此前缺失),`selftest_gui.py` 48 项 ALL PASS。图标改动需在其原仓库提交。 ## 3. 开发需求与计划(已提出、尚未实现) ### 3.1 DLPilot -- 【待素材】校准图案槽位预置为空:第二排 5 个槽位为 3D 光固化测试/校准图预留 - (`file: null`);素材到位后 assets/ → 生成脚本入库 → 更新 slots → 重新打包。 +- ~~【待素材】校准图案~~(已完成,2026-10-13 `bcb03e2`):CeramixNano 系列 6 张 + 校准图(黑/白/caliPoint/circle_640p/contrast/Sharpness_XY_3)二进制直存 + assets/+release/ 平铺(用户素材,无生成脚本),slots 栏位接入、 + package_release 收集规则扩展;随 v0.4.3 之后的下次发布进 zip。 - 【待真机帧·低优先】M305 回归向量是按协议推演的(selftest_rs485.py:90 起, 0x8CA0=36000mW 为推演值):真机功率显示已实测对接,仅剩把自测向量换成真机抓包, 让无硬件回归更贴近真机。 @@ -141,13 +197,28 @@ ### 3.3 SerialMate - 【待真机】光机实机收发与温度曲线实测(M201 ×0.1 提取),当前仅回环验证。 -- 【无图标】assets/serial-mate.ico 未生成(build_exe.py 已预留路径)。 +- ~~【无图标】assets/serial-mate.ico 未生成~~(已生成并随家族统一,见 §2.6)。 - 发送批处理脚本化(延时/循环/等待应答);曲线数据导出 CSV。 ### 3.4 I2C-Inject -- 暂停开发期间不列计划;恢复时以其 README 现状为准。 +- 并入 I2C-Lab 路线(见 §3.5);本目录不再单列计划。 -### 3.5 明确不做(决策,非待办) +### 3.5 I2C-Lab 后续阶段(2026-10-02 起续接,监控已就绪见 §2.5) +- 【下一阶段】**主机模式**:probe 作 I2C 主机——总线扫描(scan)、读写 + (wr/rd),复活 I2C-Inject 择机注入语义(静置窗口避让主控轮询/重试/读回 + 校验);GUI 加寄存器读写卡。硬件 I2C 与嗅探 PIO 可并存(先停监控再发射, + 或 PIO 分频采样回看自身发射)。 +- 【规划】**从机模拟**:RP2040 硬件 I2C 从机模式 + PC 端应答表(寄存器值/ + 回调),顶替传感器验证主机驱动。 +- 【监控增强】协议语义插件(LT8619C bank/寄存器注释)、触发抓取、长记录 + 落盘、SMBus PEC/10bit 地址注记、波形按事务着色。 +- 【待真机验收 v0.1.0】清单见 `I2C-Lab/AGENTS.md` §3(枚举/实听/注入对照/ + 400kHz 波形/30min 稳定性),验收后打 tag 发布。 +- 平台结论(2026-09-30 沿用):固件 pico-sdk C(MicroPython 无从机 API、 + 嗅探无法实时流式);硬件 2× BPI-Pico-RP2040 + 2× JI2C;GP8=SDA GP9=SCL、 + USB CDC `=`/`!` 回显、挂 live 板只接 GND/SDA/SCL 不加上拉——均已落地。 + +### 3.6 明确不做(决策,非待办) - **0x606E 的 0x40/0x80 位交换不再单独实测**(2026-09-25 用户确认:当前行为正常、 符合预期,bit5 实测基线已够用;原【待硬件】项关闭)。 - TI 原始 I2C 命令透传不做:GD32 网关固件只收 ASCII M 命令集(v0.2.0 显式决策)。 @@ -222,6 +293,13 @@ python selftest.py ; python selftest_loop.py # 协议回归 + 链路 python main.py --seconds 5 # GUI 冒烟 python build_exe.py ; python package_release.py 0.1.0 # 打包 + 组装 zip python publish_gitea.py ../release/SerialMate-v0.1.0-win64.zip --insecure + +# I2C-Lab(完整清单:I2C-Lab/AGENTS.md) +cd I2C-Lab/src +python selftest.py ; python selftest_gui.py # 解码回归 + GUI 结构自检(改 i2cdec/main 后必跑) +python main.py --seconds 5 --demo # GUI 冒烟(演示模式, 无硬件) +python build_exe.py ; python package_release.py 0.1.0 # 打包 + 组装 zip(含固件 UF2) +python publish_gitea.py ../release/I2C-Lab-v0.1.0-win64.zip --insecure ``` 上游协议/寄存器语义问题先查上游仓 `protocols.md` 与 `doc/architecture/`;