firmwareloop 0.7.0__tar.gz → 0.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/PKG-INFO +10 -7
  2. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/README.md +8 -5
  3. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/firmwareloop.egg-info/PKG-INFO +10 -7
  4. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/firmwareloop.egg-info/requires.txt +1 -1
  5. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/pyproject.toml +2 -2
  6. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/__init__.py +1 -1
  7. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/fw_mcp_server.py +26 -12
  8. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/lib/ndtdbg.py +323 -38
  9. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/lib/serial_assistant.py +43 -0
  10. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/ndtdbg_cli.py +40 -10
  11. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/serial_cli.py +23 -5
  12. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/setup-agent-mcp.ps1 +2 -2
  13. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/LICENSE +0 -0
  14. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/firmwareloop.egg-info/SOURCES.txt +0 -0
  15. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/firmwareloop.egg-info/dependency_links.txt +0 -0
  16. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/firmwareloop.egg-info/entry_points.txt +0 -0
  17. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/firmwareloop.egg-info/top_level.txt +0 -0
  18. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/setup.cfg +0 -0
  19. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/acceptance-scenario.ps1 +0 -0
  20. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/build.ps1 +0 -0
  21. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/can.ps1 +0 -0
  22. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/check-qoder-mcp.ps1 +0 -0
  23. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/common/build-backends.psm1 +0 -0
  24. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/common/fw.psm1 +0 -0
  25. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/common/uart_probe.py +0 -0
  26. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/doctor.ps1 +0 -0
  27. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/flash.ps1 +0 -0
  28. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/instrument_cli.py +0 -0
  29. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/lib/__init__.py +0 -0
  30. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/lib/instruments.py +0 -0
  31. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/logic_capture.ps1 +0 -0
  32. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/logic_decode.ps1 +0 -0
  33. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/ndtdbg_bridge.ps1 +0 -0
  34. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/reset.ps1 +0 -0
  35. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/sigrok_csv_to_fwloop.py +0 -0
  36. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/test-backends.ps1 +0 -0
  37. {firmwareloop-0.7.0 → firmwareloop-0.7.2}/tools/test.ps1 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firmwareloop
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: AI Agent Firmware Development and Lab Automation Platform (Dual-Tier MCP Server)
5
5
  Author-email: WeilinZhang <19523330249@163.com>
6
6
  License: MIT
@@ -18,7 +18,7 @@ Requires-Python: >=3.10
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
20
  Requires-Dist: pyvisa>=1.14.0
21
- Requires-Dist: pyvisa-py>=0.7.0
21
+ Requires-Dist: pyvisa-py>=0.7.2
22
22
  Requires-Dist: pyserial>=3.5
23
23
  Requires-Dist: pytest>=8.0.0
24
24
  Requires-Dist: pyocd>=0.36.0
@@ -383,7 +383,7 @@ claude mcp remove --scope user agentic-hil
383
383
  ├── demo-firmware/ 示例固件(CMake;宿主编译模拟 MCU,闭环验证载体)
384
384
  ├── demo-make/ 示例固件(Make;构建测试载体)
385
385
  ├── docs/ DEPENDENCY_MATRIX / REUSE_PLAN / V0.0.2_GAP_VERIFICATION
386
- ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.7.0)
386
+ ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.7.2)
387
387
  ├── .mcp.example.json 双层 MCP 配置模板(firmwareloop + agentic-hil)
388
388
  ├── AGENTS.md 通用智能体规范(Antigravity / Qoder / Cursor 等)
389
389
  ├── CLAUDE.md Claude Code CLI 指南
@@ -393,7 +393,7 @@ claude mcp remove --scope user agentic-hil
393
393
 
394
394
  ---
395
395
 
396
- ## 能力验证状态(v0.7.0
396
+ ## 能力验证状态(v0.7.2
397
397
 
398
398
  > 状态定义:`Implemented`(已实现)/ `Simulator Validated`(模拟验证)/
399
399
  > `Real Hardware Validated`(真机验证)/ `Experimental` / `Not Implemented`
@@ -416,7 +416,7 @@ claude mcp remove --scope user agentic-hil
416
416
  | 自动修复闭环(M6 载体) | Simulator Validated -> Real Hardware Validated 待 DUT |
417
417
  | NdtBox 数据采集/调试(fw_ndtdbg_status / fw_ndtdbg_measure, ChipSea Fxx IIC) | Implemented(无硬件 Fail-Closed 已验证)-> Real Hardware Validated 待 NdtBox |
418
418
  | NdtBox 目标断电复位(fw_ndtdbg_power_cycle, 仅 io10 断电复位, 无写闸门) | Implemented(单测覆盖时序/探针缺失指引)-> Real Hardware Validated 待 NdtBox |
419
- | NdtBox IIC 固件烧录(fw_ndtdbg_flash, .bin/.nfw 整片擦写, 芯海 Fxx) | Real Hardware Validated(84.87.1.1 / 84.87.1.2 真机整片擦写 verify pass;双闸门 Fail-Closed)+ v0.7.0 boot 判据后移/整片重写(待真机复测首试成功率) |
419
+ | NdtBox IIC 固件烧录(fw_ndtdbg_flash, .bin/.nfw 整片擦写, 芯海 Fxx) | Real Hardware Validated(84.87.1.1 / 84.87.1.2 真机整片擦写 verify pass; 2026-09-11 同批文件桥内单体 flash 6/6 pass;双闸门 Fail-Closed)。**v0.7.2 起默认引擎 = 桥内单体 flash**(逐笔 serve 路径现场实测"擦除生效但页写不落地",保留 `flash_engine: serve` 供 A/B);差分 boot 判据 + `boot_judge: off` 逃生开关见 0.7.1 |
420
420
  | 抓开机日志(fw_serial_capture + power_cycle_off_ms, 窗口内断电复位) | Implemented(simulator 验证窗口与复位串联)-> Real Hardware Validated 待 NdtBox + DUT |
421
421
 
422
422
  ---
@@ -430,10 +430,13 @@ claude mcp remove --scope user agentic-hil
430
430
  | 写操作 `PERMISSION_DENIED` | 全局 `lab.yaml` 的 `ndtdbg.write_allowed` 为 `false` → 临时改 `true` 并重启 Agent(测试完建议改回 `false`) |
431
431
  | sigrok 报 `Failed to receive reply to EP1 command 0x7d` | Saleae 驱动不是 libusb-win32 → 用 Zadig 重装;或设备被 Logic 软件占用(先退出) |
432
432
  | `fw_logic_decode` 空帧 | 抓取窗口内无总线事务 → 改用 `fw_ndtdbg_capture_seq`(抓取与寄存器序列同窗口进行),不要串行先抓后写 |
433
- | 烧录报 `BOOT_NOT_ENTERED`(擦除后 0x0000 仍读回数据) | 目标没进 boot ROM(应答的是运行态 App)→ 仅对目标板断电重启(勿动 BOX)后重试;反复出现时确认 `off_ms`(默认 500ms,独立看门狗芯片需真 POR)与 io10 供电复位接线 |
434
- | `fw_ndtdbg_flash` 报 `TARGET_MISMATCH: 烧录校验失败 @0x....` | 响应已带 `stage_timings`/`probe_hex`/`first_mismatch_*`:先看 `probe_hex`(非 FFFFFFFF = 根本没进 boot,属上一条),再看失配偏移是否恒定(恒定=页写未落地);整片重写次数由 `rewrite_times` 控制(默认 3) |
433
+ | 烧录报 `BOOT_NOT_ENTERED`(擦除魔数前后 0x0000 读回**完全相同**的非平凡数据) | 差分判据认定应答者仍是运行态 App(擦除被忽略)→ 仅对目标板断电重启(勿动 BOX)后重试;反复出现时确认 `off_ms`(默认 500ms,独立看门狗芯片需真 POR)与 io10 供电复位接线。若你确认目标 ROM 的 0x0000 语义特殊,用 `boot_judge: off`(lab.yaml/CLI `--boot-judge off`/MCP `boot_judge`)关闭判据,让逐窗校验做唯一判据 |
434
+ | `fw_ndtdbg_flash` 报 `TARGET_MISMATCH: 烧录校验失败 @0x....` | 校验是**权威判据**(不是探测启发式)。信封带 `stage_timings`/`probe_before_hex`/`probe_hex`/`boot_evidence`/`first_mismatch_*`:`boot_evidence=erase_effective` 说明擦除生效 → 看失配偏移是否恒定(恒定=页写未落地);`inconclusive` 说明探测无区分度(擦除后读回全 00/全 FF)→ 需人工确认是否真进了 boot;整片重写次数由 `rewrite_times` 控制(默认 3) |
435
435
  | 串口 `SERIAL_PORT_ERROR: ... PermissionError(13)` | 端口被 GUI 上位机独占(NDT Debug Tool / STDataCollection.exe、串口助手)→ 关闭占用方后 `fw_serial_open`;残留会话用 `fw_serial_close`(已自动等待/强杀进程,端口释放可验证) |
436
436
  | `fw_serial_status` 的 `error_class` 为 `UART_BUSY` | 会话未打开或已被占用 → 先 `fw_serial_open`;残留会话 `fw_serial_close` 清理 |
437
+ | `fw_serial_*` 报 `PermissionError(13)` / `rx_bytes: 0` | 串口被独占:**退出 NDT Debug Tool GUI(STDataCollection.exe)**——它打开时会同时占住 Box 串口与 USB-IIC 通道;失败信封/`fw_serial_status` 的 `suspected_holders` 会直接列出嫌疑进程(注意别杀 MCP server 的 python) |
438
+ | 烧录"擦除生效(probe 从非 00 变 00)但校验读回全 FF"、`pages_written` 已发出 | v0.7.1 及更早的逐笔 serve 路径问题 → v0.7.2 起默认走 `flash_engine: bridge`(桥内单体,与 GUI 同进程模型);要复现/排查旧行为用 `flash_engine: "serve"` |
439
+ | 长调用(capture/measure)客户端超时 | MCP stdio 循环是**单线程**的:长调用会让同会话其它调用排队(含 3s 的小调用)→ 缩短窗口,或用 `fw_ndtdbg_stream_start` job 模式 |
437
440
 
438
441
  完整速查表与手工部署细节见 [`Fwloop_setup_pack/README.md`](Fwloop_setup_pack/README.md)。
439
442
 
@@ -355,7 +355,7 @@ claude mcp remove --scope user agentic-hil
355
355
  ├── demo-firmware/ 示例固件(CMake;宿主编译模拟 MCU,闭环验证载体)
356
356
  ├── demo-make/ 示例固件(Make;构建测试载体)
357
357
  ├── docs/ DEPENDENCY_MATRIX / REUSE_PLAN / V0.0.2_GAP_VERIFICATION
358
- ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.7.0)
358
+ ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.7.2)
359
359
  ├── .mcp.example.json 双层 MCP 配置模板(firmwareloop + agentic-hil)
360
360
  ├── AGENTS.md 通用智能体规范(Antigravity / Qoder / Cursor 等)
361
361
  ├── CLAUDE.md Claude Code CLI 指南
@@ -365,7 +365,7 @@ claude mcp remove --scope user agentic-hil
365
365
 
366
366
  ---
367
367
 
368
- ## 能力验证状态(v0.7.0
368
+ ## 能力验证状态(v0.7.2
369
369
 
370
370
  > 状态定义:`Implemented`(已实现)/ `Simulator Validated`(模拟验证)/
371
371
  > `Real Hardware Validated`(真机验证)/ `Experimental` / `Not Implemented`
@@ -388,7 +388,7 @@ claude mcp remove --scope user agentic-hil
388
388
  | 自动修复闭环(M6 载体) | Simulator Validated -> Real Hardware Validated 待 DUT |
389
389
  | NdtBox 数据采集/调试(fw_ndtdbg_status / fw_ndtdbg_measure, ChipSea Fxx IIC) | Implemented(无硬件 Fail-Closed 已验证)-> Real Hardware Validated 待 NdtBox |
390
390
  | NdtBox 目标断电复位(fw_ndtdbg_power_cycle, 仅 io10 断电复位, 无写闸门) | Implemented(单测覆盖时序/探针缺失指引)-> Real Hardware Validated 待 NdtBox |
391
- | NdtBox IIC 固件烧录(fw_ndtdbg_flash, .bin/.nfw 整片擦写, 芯海 Fxx) | Real Hardware Validated(84.87.1.1 / 84.87.1.2 真机整片擦写 verify pass;双闸门 Fail-Closed)+ v0.7.0 boot 判据后移/整片重写(待真机复测首试成功率) |
391
+ | NdtBox IIC 固件烧录(fw_ndtdbg_flash, .bin/.nfw 整片擦写, 芯海 Fxx) | Real Hardware Validated(84.87.1.1 / 84.87.1.2 真机整片擦写 verify pass; 2026-09-11 同批文件桥内单体 flash 6/6 pass;双闸门 Fail-Closed)。**v0.7.2 起默认引擎 = 桥内单体 flash**(逐笔 serve 路径现场实测"擦除生效但页写不落地",保留 `flash_engine: serve` 供 A/B);差分 boot 判据 + `boot_judge: off` 逃生开关见 0.7.1 |
392
392
  | 抓开机日志(fw_serial_capture + power_cycle_off_ms, 窗口内断电复位) | Implemented(simulator 验证窗口与复位串联)-> Real Hardware Validated 待 NdtBox + DUT |
393
393
 
394
394
  ---
@@ -402,10 +402,13 @@ claude mcp remove --scope user agentic-hil
402
402
  | 写操作 `PERMISSION_DENIED` | 全局 `lab.yaml` 的 `ndtdbg.write_allowed` 为 `false` → 临时改 `true` 并重启 Agent(测试完建议改回 `false`) |
403
403
  | sigrok 报 `Failed to receive reply to EP1 command 0x7d` | Saleae 驱动不是 libusb-win32 → 用 Zadig 重装;或设备被 Logic 软件占用(先退出) |
404
404
  | `fw_logic_decode` 空帧 | 抓取窗口内无总线事务 → 改用 `fw_ndtdbg_capture_seq`(抓取与寄存器序列同窗口进行),不要串行先抓后写 |
405
- | 烧录报 `BOOT_NOT_ENTERED`(擦除后 0x0000 仍读回数据) | 目标没进 boot ROM(应答的是运行态 App)→ 仅对目标板断电重启(勿动 BOX)后重试;反复出现时确认 `off_ms`(默认 500ms,独立看门狗芯片需真 POR)与 io10 供电复位接线 |
406
- | `fw_ndtdbg_flash` 报 `TARGET_MISMATCH: 烧录校验失败 @0x....` | 响应已带 `stage_timings`/`probe_hex`/`first_mismatch_*`:先看 `probe_hex`(非 FFFFFFFF = 根本没进 boot,属上一条),再看失配偏移是否恒定(恒定=页写未落地);整片重写次数由 `rewrite_times` 控制(默认 3) |
405
+ | 烧录报 `BOOT_NOT_ENTERED`(擦除魔数前后 0x0000 读回**完全相同**的非平凡数据) | 差分判据认定应答者仍是运行态 App(擦除被忽略)→ 仅对目标板断电重启(勿动 BOX)后重试;反复出现时确认 `off_ms`(默认 500ms,独立看门狗芯片需真 POR)与 io10 供电复位接线。若你确认目标 ROM 的 0x0000 语义特殊,用 `boot_judge: off`(lab.yaml/CLI `--boot-judge off`/MCP `boot_judge`)关闭判据,让逐窗校验做唯一判据 |
406
+ | `fw_ndtdbg_flash` 报 `TARGET_MISMATCH: 烧录校验失败 @0x....` | 校验是**权威判据**(不是探测启发式)。信封带 `stage_timings`/`probe_before_hex`/`probe_hex`/`boot_evidence`/`first_mismatch_*`:`boot_evidence=erase_effective` 说明擦除生效 → 看失配偏移是否恒定(恒定=页写未落地);`inconclusive` 说明探测无区分度(擦除后读回全 00/全 FF)→ 需人工确认是否真进了 boot;整片重写次数由 `rewrite_times` 控制(默认 3) |
407
407
  | 串口 `SERIAL_PORT_ERROR: ... PermissionError(13)` | 端口被 GUI 上位机独占(NDT Debug Tool / STDataCollection.exe、串口助手)→ 关闭占用方后 `fw_serial_open`;残留会话用 `fw_serial_close`(已自动等待/强杀进程,端口释放可验证) |
408
408
  | `fw_serial_status` 的 `error_class` 为 `UART_BUSY` | 会话未打开或已被占用 → 先 `fw_serial_open`;残留会话 `fw_serial_close` 清理 |
409
+ | `fw_serial_*` 报 `PermissionError(13)` / `rx_bytes: 0` | 串口被独占:**退出 NDT Debug Tool GUI(STDataCollection.exe)**——它打开时会同时占住 Box 串口与 USB-IIC 通道;失败信封/`fw_serial_status` 的 `suspected_holders` 会直接列出嫌疑进程(注意别杀 MCP server 的 python) |
410
+ | 烧录"擦除生效(probe 从非 00 变 00)但校验读回全 FF"、`pages_written` 已发出 | v0.7.1 及更早的逐笔 serve 路径问题 → v0.7.2 起默认走 `flash_engine: bridge`(桥内单体,与 GUI 同进程模型);要复现/排查旧行为用 `flash_engine: "serve"` |
411
+ | 长调用(capture/measure)客户端超时 | MCP stdio 循环是**单线程**的:长调用会让同会话其它调用排队(含 3s 的小调用)→ 缩短窗口,或用 `fw_ndtdbg_stream_start` job 模式 |
409
412
 
410
413
  完整速查表与手工部署细节见 [`Fwloop_setup_pack/README.md`](Fwloop_setup_pack/README.md)。
411
414
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firmwareloop
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: AI Agent Firmware Development and Lab Automation Platform (Dual-Tier MCP Server)
5
5
  Author-email: WeilinZhang <19523330249@163.com>
6
6
  License: MIT
@@ -18,7 +18,7 @@ Requires-Python: >=3.10
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
20
  Requires-Dist: pyvisa>=1.14.0
21
- Requires-Dist: pyvisa-py>=0.7.0
21
+ Requires-Dist: pyvisa-py>=0.7.2
22
22
  Requires-Dist: pyserial>=3.5
23
23
  Requires-Dist: pytest>=8.0.0
24
24
  Requires-Dist: pyocd>=0.36.0
@@ -383,7 +383,7 @@ claude mcp remove --scope user agentic-hil
383
383
  ├── demo-firmware/ 示例固件(CMake;宿主编译模拟 MCU,闭环验证载体)
384
384
  ├── demo-make/ 示例固件(Make;构建测试载体)
385
385
  ├── docs/ DEPENDENCY_MATRIX / REUSE_PLAN / V0.0.2_GAP_VERIFICATION
386
- ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.7.0)
386
+ ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.7.2)
387
387
  ├── .mcp.example.json 双层 MCP 配置模板(firmwareloop + agentic-hil)
388
388
  ├── AGENTS.md 通用智能体规范(Antigravity / Qoder / Cursor 等)
389
389
  ├── CLAUDE.md Claude Code CLI 指南
@@ -393,7 +393,7 @@ claude mcp remove --scope user agentic-hil
393
393
 
394
394
  ---
395
395
 
396
- ## 能力验证状态(v0.7.0
396
+ ## 能力验证状态(v0.7.2
397
397
 
398
398
  > 状态定义:`Implemented`(已实现)/ `Simulator Validated`(模拟验证)/
399
399
  > `Real Hardware Validated`(真机验证)/ `Experimental` / `Not Implemented`
@@ -416,7 +416,7 @@ claude mcp remove --scope user agentic-hil
416
416
  | 自动修复闭环(M6 载体) | Simulator Validated -> Real Hardware Validated 待 DUT |
417
417
  | NdtBox 数据采集/调试(fw_ndtdbg_status / fw_ndtdbg_measure, ChipSea Fxx IIC) | Implemented(无硬件 Fail-Closed 已验证)-> Real Hardware Validated 待 NdtBox |
418
418
  | NdtBox 目标断电复位(fw_ndtdbg_power_cycle, 仅 io10 断电复位, 无写闸门) | Implemented(单测覆盖时序/探针缺失指引)-> Real Hardware Validated 待 NdtBox |
419
- | NdtBox IIC 固件烧录(fw_ndtdbg_flash, .bin/.nfw 整片擦写, 芯海 Fxx) | Real Hardware Validated(84.87.1.1 / 84.87.1.2 真机整片擦写 verify pass;双闸门 Fail-Closed)+ v0.7.0 boot 判据后移/整片重写(待真机复测首试成功率) |
419
+ | NdtBox IIC 固件烧录(fw_ndtdbg_flash, .bin/.nfw 整片擦写, 芯海 Fxx) | Real Hardware Validated(84.87.1.1 / 84.87.1.2 真机整片擦写 verify pass; 2026-09-11 同批文件桥内单体 flash 6/6 pass;双闸门 Fail-Closed)。**v0.7.2 起默认引擎 = 桥内单体 flash**(逐笔 serve 路径现场实测"擦除生效但页写不落地",保留 `flash_engine: serve` 供 A/B);差分 boot 判据 + `boot_judge: off` 逃生开关见 0.7.1 |
420
420
  | 抓开机日志(fw_serial_capture + power_cycle_off_ms, 窗口内断电复位) | Implemented(simulator 验证窗口与复位串联)-> Real Hardware Validated 待 NdtBox + DUT |
421
421
 
422
422
  ---
@@ -430,10 +430,13 @@ claude mcp remove --scope user agentic-hil
430
430
  | 写操作 `PERMISSION_DENIED` | 全局 `lab.yaml` 的 `ndtdbg.write_allowed` 为 `false` → 临时改 `true` 并重启 Agent(测试完建议改回 `false`) |
431
431
  | sigrok 报 `Failed to receive reply to EP1 command 0x7d` | Saleae 驱动不是 libusb-win32 → 用 Zadig 重装;或设备被 Logic 软件占用(先退出) |
432
432
  | `fw_logic_decode` 空帧 | 抓取窗口内无总线事务 → 改用 `fw_ndtdbg_capture_seq`(抓取与寄存器序列同窗口进行),不要串行先抓后写 |
433
- | 烧录报 `BOOT_NOT_ENTERED`(擦除后 0x0000 仍读回数据) | 目标没进 boot ROM(应答的是运行态 App)→ 仅对目标板断电重启(勿动 BOX)后重试;反复出现时确认 `off_ms`(默认 500ms,独立看门狗芯片需真 POR)与 io10 供电复位接线 |
434
- | `fw_ndtdbg_flash` 报 `TARGET_MISMATCH: 烧录校验失败 @0x....` | 响应已带 `stage_timings`/`probe_hex`/`first_mismatch_*`:先看 `probe_hex`(非 FFFFFFFF = 根本没进 boot,属上一条),再看失配偏移是否恒定(恒定=页写未落地);整片重写次数由 `rewrite_times` 控制(默认 3) |
433
+ | 烧录报 `BOOT_NOT_ENTERED`(擦除魔数前后 0x0000 读回**完全相同**的非平凡数据) | 差分判据认定应答者仍是运行态 App(擦除被忽略)→ 仅对目标板断电重启(勿动 BOX)后重试;反复出现时确认 `off_ms`(默认 500ms,独立看门狗芯片需真 POR)与 io10 供电复位接线。若你确认目标 ROM 的 0x0000 语义特殊,用 `boot_judge: off`(lab.yaml/CLI `--boot-judge off`/MCP `boot_judge`)关闭判据,让逐窗校验做唯一判据 |
434
+ | `fw_ndtdbg_flash` 报 `TARGET_MISMATCH: 烧录校验失败 @0x....` | 校验是**权威判据**(不是探测启发式)。信封带 `stage_timings`/`probe_before_hex`/`probe_hex`/`boot_evidence`/`first_mismatch_*`:`boot_evidence=erase_effective` 说明擦除生效 → 看失配偏移是否恒定(恒定=页写未落地);`inconclusive` 说明探测无区分度(擦除后读回全 00/全 FF)→ 需人工确认是否真进了 boot;整片重写次数由 `rewrite_times` 控制(默认 3) |
435
435
  | 串口 `SERIAL_PORT_ERROR: ... PermissionError(13)` | 端口被 GUI 上位机独占(NDT Debug Tool / STDataCollection.exe、串口助手)→ 关闭占用方后 `fw_serial_open`;残留会话用 `fw_serial_close`(已自动等待/强杀进程,端口释放可验证) |
436
436
  | `fw_serial_status` 的 `error_class` 为 `UART_BUSY` | 会话未打开或已被占用 → 先 `fw_serial_open`;残留会话 `fw_serial_close` 清理 |
437
+ | `fw_serial_*` 报 `PermissionError(13)` / `rx_bytes: 0` | 串口被独占:**退出 NDT Debug Tool GUI(STDataCollection.exe)**——它打开时会同时占住 Box 串口与 USB-IIC 通道;失败信封/`fw_serial_status` 的 `suspected_holders` 会直接列出嫌疑进程(注意别杀 MCP server 的 python) |
438
+ | 烧录"擦除生效(probe 从非 00 变 00)但校验读回全 FF"、`pages_written` 已发出 | v0.7.1 及更早的逐笔 serve 路径问题 → v0.7.2 起默认走 `flash_engine: bridge`(桥内单体,与 GUI 同进程模型);要复现/排查旧行为用 `flash_engine: "serve"` |
439
+ | 长调用(capture/measure)客户端超时 | MCP stdio 循环是**单线程**的:长调用会让同会话其它调用排队(含 3s 的小调用)→ 缩短窗口,或用 `fw_ndtdbg_stream_start` job 模式 |
437
440
 
438
441
  完整速查表与手工部署细节见 [`Fwloop_setup_pack/README.md`](Fwloop_setup_pack/README.md)。
439
442
 
@@ -1,5 +1,5 @@
1
1
  pyvisa>=1.14.0
2
- pyvisa-py>=0.7.0
2
+ pyvisa-py>=0.7.2
3
3
  pyserial>=3.5
4
4
  pytest>=8.0.0
5
5
  pyocd>=0.36.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "firmwareloop"
7
- version = "0.7.0"
7
+ version = "0.7.2"
8
8
  description = "AI Agent Firmware Development and Lab Automation Platform (Dual-Tier MCP Server)"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -14,7 +14,7 @@ license = { text = "MIT" }
14
14
  requires-python = ">=3.10"
15
15
  dependencies = [
16
16
  "pyvisa>=1.14.0",
17
- "pyvisa-py>=0.7.0",
17
+ "pyvisa-py>=0.7.2",
18
18
  "pyserial>=3.5",
19
19
  "pytest>=8.0.0",
20
20
  "pyocd>=0.36.0",
@@ -2,4 +2,4 @@
2
2
  FirmwareLoop - AI Agent Firmware Development and Lab Automation Platform.
3
3
  """
4
4
 
5
- __version__ = "0.7.0"
5
+ __version__ = "0.7.2"
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- fw_mcp_server.py - FirmwareLoop High-Level Workflow & Device MCP Server (v0.7.0).
3
+ fw_mcp_server.py - FirmwareLoop High-Level Workflow & Device MCP Server (v0.7.2).
4
4
 
5
5
  Exposes high-level firmware engineering and hardware management tools to AI Coding
6
6
  Agents (Antigravity CLI, Claude Code CLI, Qoder IDE, CodeBuddy IDE, Cursor) via the Model Context
@@ -332,7 +332,7 @@ def handle_fw_ndtdbg_write_reg(args: Dict[str, Any]) -> Dict[str, Any]:
332
332
  def handle_fw_ndtdbg_flash(args: Dict[str, Any]) -> Dict[str, Any]:
333
333
  """Erase + flash a .bin/.nfw image onto the NdtBox target chip (ChipSea Fxx) over IIC.
334
334
 
335
- P1-3 (0.7.0): 去掉按签名重试 —— "TARGET_MISMATCH @0x0000" 正是"根本没进 boot"的
335
+ P1-3 (0.7.2): 去掉按签名重试 —— "TARGET_MISMATCH @0x0000" 正是"根本没进 boot"的
336
336
  特征签名 (旧 boot 判据假阳的同一条错误路径), 盲目重试只会重复同一失败路径。
337
337
  整片重写 (<= rewrite_times) 现由 flash_exec 内部完成, 结果带 rewrite_count;
338
338
  BOOT_NOT_ENTERED 直接返回指引 (需断电重启, 重试无意义)。
@@ -344,6 +344,10 @@ def handle_fw_ndtdbg_flash(args: Dict[str, Any]) -> Dict[str, Any]:
344
344
  cmd.extend(["--off-ms", str(int(args["off_ms"]))])
345
345
  if args.get("rewrite_times") is not None:
346
346
  cmd.extend(["--rewrite-times", str(max(0, int(args["rewrite_times"])))])
347
+ if args.get("boot_judge"):
348
+ cmd.extend(["--boot-judge", str(args["boot_judge"])])
349
+ if args.get("flash_engine"):
350
+ cmd.extend(["--flash-engine", str(args["flash_engine"])])
347
351
  if args.get("confirm"):
348
352
  cmd.append("--confirm")
349
353
  _ndtdbg_reg_args(args, cmd)
@@ -1677,7 +1681,7 @@ TOOLS_REGISTRY = {
1677
1681
  "handler": handle_fw_ndtdbg_status,
1678
1682
  },
1679
1683
  "fw_ndtdbg_measure": {
1680
- "description": "Poll NdtBox IIC debug data stream (ChipSea Fxx) for N seconds: frame count, checksum errors, channel samples. Read-only; firmware upgrade is a separate future capability.",
1684
+ "description": "Poll NdtBox IIC debug data stream (ChipSea Fxx) for N seconds: frame count, checksum errors, channel samples. Read-only. NOTE: the MCP stdio loop is single-threaded, so a long call blocks every other call in the same session (a concurrent short call just waits and may time out client-side) - keep duration_seconds small; use fw_ndtdbg_stream_start for long captures.",
1681
1685
  "inputSchema": {
1682
1686
  "type": "object",
1683
1687
  "properties": {
@@ -1770,7 +1774,7 @@ TOOLS_REGISTRY = {
1770
1774
  "handler": handle_fw_ndtdbg_write_reg,
1771
1775
  },
1772
1776
  "fw_ndtdbg_flash": {
1773
- "description": "Erase and flash a .bin/.nfw firmware image onto the NdtBox target chip (ChipSea Fxx) over IIC via NdtDebugTool (power-cycle reset into boot -> erase magic AA 55 A5 5A -> BOOT JUDGEMENT: 0x0000 must read all-0xFF, a running App answers its own static registers and is rejected with BOOT_NOT_ENTERED -> 128B page writes -> 256B read-back verify, whole-chip rewrite on mismatch -> finish 'PASs'). DESTRUCTIVE full-chip erase: requires lab.yaml ndtdbg.write_allowed: true AND confirm: true, otherwise PERMISSION_DENIED.",
1777
+ "description": "Erase and flash a .bin/.nfw firmware image onto the NdtBox target chip (ChipSea Fxx) over IIC via NdtDebugTool. Default engine 'bridge' runs the whole UpdateFW sequence in ONE 32-bit process holding both the IIC and power-rail handles (power-cycle into boot -> erase magic AA 55 A5 5A -> 128B page writes -> 256B read-back verify per window = the authoritative criterion -> finish 'PASs'), which is the path validated 6/6 on real hardware; engine 'serve' drives one raw transaction per page from Python (kept for A/B reproduction - field evidence showed it can report erase-effective but write nothing on the same frames). DESTRUCTIVE full-chip erase: requires lab.yaml ndtdbg.write_allowed: true AND confirm: true, otherwise PERMISSION_DENIED.",
1774
1778
  "inputSchema": {
1775
1779
  "type": "object",
1776
1780
  "properties": {
@@ -1799,7 +1803,17 @@ TOOLS_REGISTRY = {
1799
1803
  },
1800
1804
  "rewrite_times": {
1801
1805
  "type": "integer",
1802
- "description": "Whole-chip rewrite budget when read-back verify mismatches (aligned with ChipSea.cs REWRITE_TIMES). Default 3; 0 disables rewriting. The result carries rewrite_count/attempts, and a failed flash reports stage_timings/probe_hex/first_mismatch_* for post-mortem",
1806
+ "description": "Whole-chip rewrite budget when read-back verify mismatches (aligned with ChipSea.cs REWRITE_TIMES). Default 3; 0 disables rewriting. The result carries rewrite_count/attempts, and a failed flash reports stage_timings/probe_before_hex/probe_hex/boot_evidence/first_mismatch_* for post-mortem",
1807
+ },
1808
+ "boot_judge": {
1809
+ "type": "string",
1810
+ "enum": ["diff", "off"],
1811
+ "description": "Boot-probe criterion (serve engine only). 'diff' (default): compare 0x0000 before the power cycle vs after the erase, and raise BOOT_NOT_ENTERED only when both reads are identical NON-TRIVIAL data (proof the same running App answered the erase magic). 'off': only record probe evidence (probe_before_hex/probe_hex/boot_evidence) and never block - the 256B read-back verify stays the sole criterion, i.e. exactly the bridge/GUI behaviour. Use 'off' if a target ROM reports an unexpected 0x0000 signature",
1812
+ },
1813
+ "flash_engine": {
1814
+ "type": "string",
1815
+ "enum": ["bridge", "serve"],
1816
+ "description": "Flash transport. 'bridge' (default): whole sequence inside one 32-bit process, frames passed once via stdin, both device handles held - real-hardware validated (6/6). 'serve': one raw I2C transaction per page driven from Python (kept for A/B; field evidence: same frames reported 'erase effective' yet wrote nothing). Use 'serve' only to reproduce/diagnose that case",
1803
1817
  },
1804
1818
  "dll_dir": {
1805
1819
  "type": "string",
@@ -2235,7 +2249,7 @@ TOOLS_REGISTRY = {
2235
2249
  "handler": handle_fw_serial_parse,
2236
2250
  },
2237
2251
  "fw_serial_capture": {
2238
- "description": "Serial assistant: synchronous window capture - record the RX stream for duration_ms into a timestamped CSV (t_rel_ms, dir, hex, printable) for offline analysis/plotting (real-time-curve equivalent; plotting happens in your tools/Agent from the CSV). Pass power_cycle_off_ms to fire an NdtBox power-cycle inside the window and capture boot-time prints (port is opened first). duration_ms <= 90000; long background captures (job mode) are a planned follow-up.",
2252
+ "description": "Serial assistant: synchronous window capture - record the RX stream for duration_ms into a timestamped CSV (t_rel_ms, dir, hex, printable) for offline analysis/plotting (real-time-curve equivalent; plotting happens in your tools/Agent from the CSV). Pass power_cycle_off_ms to fire an NdtBox power-cycle inside the window and capture boot-time prints (port is opened first). duration_ms <= 90000; long background captures (job mode) are a planned follow-up. NOTE: the MCP stdio loop is single-threaded, so this call blocks every other call in the same session for the whole window (your client may time out first) - prefer windows of a few seconds and call it when nothing else needs the box.",
2239
2253
  "inputSchema": {
2240
2254
  "type": "object",
2241
2255
  "properties": {
@@ -2300,7 +2314,7 @@ def process_request(req: Dict[str, Any]) -> Optional[Dict[str, Any]]:
2300
2314
  },
2301
2315
  "serverInfo": {
2302
2316
  "name": "firmwareloop",
2303
- "version": "0.7.0"
2317
+ "version": "0.7.2"
2304
2318
  }
2305
2319
  }
2306
2320
  }
@@ -2435,7 +2449,7 @@ def _settle_stashed_entrypoints(stashed) -> None:
2435
2449
  def handle_cli_update() -> int:
2436
2450
  """Handle `firmwareloop update` command."""
2437
2451
  print("============================================================")
2438
- print(" FirmwareLoop Auto-Updater (v0.7.0)")
2452
+ print(" FirmwareLoop Auto-Updater (v0.7.2)")
2439
2453
  print("============================================================")
2440
2454
 
2441
2455
  is_git_repo = os.path.exists(os.path.join(REPO_ROOT, ".git"))
@@ -2866,7 +2880,7 @@ def set_ndtdbg_dlldir(dll_path: str) -> str:
2866
2880
  def handle_cli_setup() -> int:
2867
2881
  """Handle `firmwareloop setup` command: simultaneously configure Antigravity CLI, Claude Code CLI, Qoder IDE, and CodeBuddy IDE."""
2868
2882
  print("============================================================")
2869
- print(" FirmwareLoop Multi-Agent Global Setup Helper (v0.7.0)")
2883
+ print(" FirmwareLoop Multi-Agent Global Setup Helper (v0.7.2)")
2870
2884
  print("============================================================")
2871
2885
  print("")
2872
2886
 
@@ -2991,7 +3005,7 @@ def handle_cli_setup() -> int:
2991
3005
  def handle_cli_init(chip: Optional[str] = None, upgrade: bool = False) -> int:
2992
3006
  """Handle `firmwareloop init [target_chip]` command."""
2993
3007
  print("============================================================")
2994
- print(" FirmwareLoop Multi-Agent Project Initializer (v0.7.0)")
3008
+ print(" FirmwareLoop Multi-Agent Project Initializer (v0.7.2)")
2995
3009
  print("============================================================")
2996
3010
  cwd = os.getcwd()
2997
3011
  print(f"[*] Initializing multi-agent guidelines & bench config in:\n {cwd}\n")
@@ -3024,7 +3038,7 @@ def handle_cli_init(chip: Optional[str] = None, upgrade: bool = False) -> int:
3024
3038
 
3025
3039
 
3026
3040
  def print_cli_help() -> None:
3027
- print("""FirmwareLoop (fwloop) — AI Agent Firmware Engineering & Lab Automation Platform (v0.7.0)
3041
+ print("""FirmwareLoop (fwloop) — AI Agent Firmware Engineering & Lab Automation Platform (v0.7.2)
3028
3042
 
3029
3043
  Usage:
3030
3044
  fwloop [command] (or: firmwareloop [command])
@@ -3070,7 +3084,7 @@ def main() -> None:
3070
3084
  sys.exit(2)
3071
3085
  sys.exit(handle_cli_ndtdebugtool(args[1]))
3072
3086
  elif cmd in ["version", "-v", "--version"]:
3073
- print("FirmwareLoop v0.7.0")
3087
+ print("FirmwareLoop v0.7.2")
3074
3088
  sys.exit(0)
3075
3089
  elif cmd in ["help", "-h", "--help"]:
3076
3090
  print_cli_help()
@@ -11,6 +11,7 @@ import contextlib
11
11
  import json
12
12
  import os
13
13
  import queue
14
+ import re
14
15
  import subprocess
15
16
  import sys
16
17
  import threading
@@ -262,9 +263,9 @@ PAGE_READ_GAP_MS = 10 # 回读窗口间隔 (ReadFwAndCheck Thread.Sleep(10)
262
263
  def _flash_envelope(timings: dict | None = None, **extra) -> dict:
263
264
  """P1-2: 烧录成功/失败共用同一组诊断字段。
264
265
 
265
- 分阶段耗时 (stage_timings) + boot 探测原始数据 (probe_hex) + 首个失配点
266
- (first_mismatch_offset/expect/got) + 页数与重写次数——失败不再是"只有一句话",
267
- 能当场区分 "探测假阳 (根本没进 boot)" "进了 boot 但页写没落地"。
266
+ 分阶段耗时 (stage_timings) + boot 探测原始数据 (probe_before_hex/probe_hex) +
267
+ 判据结论 (boot_evidence) + 首个失配点 (first_mismatch_offset/expect/got) +
268
+ 页数与重写次数——失败不再是"只有一句话", 能当场区分"根本没进 boot"与"页写没落地"。
268
269
  """
269
270
  out: dict = {"stage_timings": dict(timings or {})}
270
271
  for key, val in extra.items():
@@ -273,6 +274,38 @@ def _flash_envelope(timings: dict | None = None, **extra) -> dict:
273
274
  return out
274
275
 
275
276
 
277
+ BOOT_JUDGE_MODES = ("diff", "off")
278
+
279
+
280
+ def classify_boot_evidence(before: bytes | None, after: bytes | None) -> str:
281
+ """烧录 boot 判据 (0.7.1 差分判据, 取代被实战证伪的"擦除后必须全 FF")。
282
+
283
+ 返回: 'app_still_answering' (唯一熔断情形) | 'erase_effective' | 'inconclusive' | 'not_evaluated'
284
+
285
+ 实战铁证 (2026-09-11, CSA37F72): 擦除后读 0x0000 = `00 00 00 00` 而非 FF ——
286
+ 桥内单体 flash (无判据、时序同 GUI) 对同一批文件全部 verify pass ⇒ "全 FF" 只是
287
+ 该 ROM 的**未验证假设**, 一旦当硬判据就会永久误判 BOOT_NOT_ENTERED。
288
+
289
+ 现判据只在"有正面证据表明应答者仍是运行态固件"时熔断:
290
+ * 两次读数不同 (含"有应答 -> 无应答") ⇒ 擦除生效 ⇒ 继续;
291
+ * 任一侧无应答 (None) ⇒ 无法比对 ⇒ 不确定, 继续;
292
+ * 两次相同但内容是全 00/全 FF ⇒ 无法区分 (本 ROM 擦除后正是全 00, 而某些固件
293
+ 的静态寄存器也可能全 0) ⇒ 不确定, 继续;
294
+ * 两次相同且是非平凡数据 (如 `25 01 18 01`) ⇒ 擦除魔数 (boot ROM 专属命令)
295
+ 前后是同一条应答 ⇒ 魔数被忽略, 应答者仍是 App ⇒ 熔断。
296
+ 一切不确定情形都放行, 由页写后的逐窗回读校验做权威兜底 (fail-closed)。
297
+ """
298
+ if before is None and after is None:
299
+ return "inconclusive"
300
+ if before is None or after is None:
301
+ return "erase_effective" # 一侧有内容: 相较之下状态变了
302
+ if before != after:
303
+ return "erase_effective"
304
+ if all(b in (0x00, 0xFF) for b in after):
305
+ return "inconclusive" # 全 00/全 FF 无区分度
306
+ return "app_still_answering"
307
+
308
+
276
309
  def load_firmware(file_path: str) -> tuple[str, bytes]:
277
310
  """读取固件: .nfw 去 256 头 / .bin 128 对齐 0xFF 补齐 (ChipSea.cs LoadFile)。返回 (类型, 载荷)。"""
278
311
  if not os.path.isfile(file_path):
@@ -370,13 +403,19 @@ class NdtBoxIo:
370
403
  return self._persistent
371
404
 
372
405
  def _with_sess(self, fn):
406
+ """在活跃会话上执行单事务。
407
+
408
+ `with io.session():` 块内 self._persistent 已置位 ⇒ 复用同一个 32 位桥进程与
409
+ USB 会话 (事务延迟 = 一次管道往返); 块外为即用即关 (spawn+open+事务+close)。
410
+ (现场曾怀疑此处未复用导致 77ms/页——每笔新 spawn 是 ~1.5s 量级, 与 77ms 不符,
411
+ 故复用确实生效; 该现象的真因见 flash_exec 的引擎说明。)
412
+ """
373
413
  sess = self._get_live_sess()
374
- if self._persistent is sess:
375
- return fn(sess)
376
414
  try:
377
415
  return fn(sess)
378
416
  finally:
379
- sess.close()
417
+ if sess is not self._persistent:
418
+ sess.close()
380
419
 
381
420
  # -- 芯片无关原语 (对齐 IIC.cs / I2CProxy.cs) --
382
421
 
@@ -392,7 +431,18 @@ class NdtBoxIo:
392
431
  return self._with_sess(lambda s: s.read_reg(reg, length))
393
432
 
394
433
  def write_reg(self, reg: int, data: bytes) -> None:
395
- self._with_sess(lambda s: s.write_reg(reg, data))
434
+ try:
435
+ self._with_sess(lambda s: s.write_reg(reg, data))
436
+ except NdtBoxError as exc:
437
+ if exc.error_class != "TARGET_MISMATCH":
438
+ raise
439
+ # P2-1: 写 NACK ≠ 从机死了 —— 现场实证"读正常但写稳定失败"
440
+ raise NdtBoxError(
441
+ exc.error_class, exc.message, exc.detail,
442
+ {"hint": "写事务无应答与'从机整体不应答'不同: 若紧随其后的读事务正常, 更可能是"
443
+ "该寄存器不可写/写保护, 或目标固件未实现该写寄存器 (也可能是写事务"
444
+ "超长/格式不符)。先用 fw_ndtdbg_read_reg 回读邻居寄存器确认链路仍活, "
445
+ "再核对寄存器语义"}) from exc
396
446
 
397
447
  def raw_write_read(self, write: bytes, read_len: int = 0) -> bytes | None:
398
448
  """原始 IIC 事务 (对齐 IIC.WriteRead): 写 write 字节(不含从机地址) 再读 read_len 字节。
@@ -615,25 +665,221 @@ class NdtBoxIo:
615
665
  "duration_actual_ms": int((time.monotonic() - started) * 1000),
616
666
  }
617
667
 
668
+ def _probe_0000(self) -> bytes | None:
669
+ """读 0x0000 4 字节 (boot 判据探测): 有应答返回字节, 无应答 (TARGET_MISMATCH) 返回 None。
670
+
671
+ BUS_TIMEOUT (clock-stretch 挂死) / PROTOCOL_ERROR (会话错位) 上抛——这两种状态下
672
+ 任何继续操作都不可信 (fail-closed)。
673
+ """
674
+ try:
675
+ return self.raw_write_read(b"\x00\x00", 4)
676
+ except NdtBoxError as exc:
677
+ if exc.error_class == "TARGET_MISMATCH":
678
+ return None
679
+ raise
680
+
681
+ def _bridge_plan_json(self, plan: dict) -> str:
682
+ """桥 flash action 的 stdin plan (只含桥认识的键)。"""
683
+ return json.dumps({
684
+ "off_ms": int(plan.get("off_ms", DEFAULT_OFF_MS)),
685
+ "erase_ms": int(plan.get("erase_ms", 2000)),
686
+ "page_gap_ms": int(plan.get("page_gap_ms", PAGE_WRITE_GAP_MS)),
687
+ "read_gap_ms": int(plan.get("read_gap_ms", PAGE_READ_GAP_MS)),
688
+ "writes": list(plan.get("writes", []) or []),
689
+ "windows": list(plan.get("windows", []) or []),
690
+ }, ensure_ascii=False)
691
+
692
+ def _bridge_flash_once(self, plan: dict) -> dict:
693
+ """单次桥内单体烧录 (ndtdbg_bridge.ps1 -Action flash): 返回 data dict, 失败抛 NdtBoxError。
694
+
695
+ 桥侧 #progress 行转发到 stderr (agent 可见进度), 末行 JSON 为结果 / 错误信封。
696
+ 桥自身无超时 ⇒ 本层按 plan 推预算守护, 超时 kill 并报 BUS_TIMEOUT (不留孤儿进程)。
697
+ """
698
+ import time
699
+ if not os.path.exists(PS32):
700
+ raise NdtBoxError("CONFIG_ERROR", f"32 位 PowerShell 不存在: {PS32}")
701
+ if not os.path.isfile(os.path.join(self.dll_dir, "USBIOX.DLL")):
702
+ raise NdtBoxError("CONFIG_ERROR", f"USBIOX.DLL 不存在: {self.dll_dir}",
703
+ "lab.yaml 的 ndtdbg.dll_dir 指向 NdtDebugTool 的 lib 目录")
704
+ args = [PS32, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
705
+ "-File", self.bridge_ps1, "-Action", "flash",
706
+ "-DllDir", self.dll_dir, "-SlaveAddr", str(self.slave_addr),
707
+ "-Speed", str(self.speed)]
708
+ pages = len(plan.get("writes") or [])
709
+ windows = len(plan.get("windows") or [])
710
+ # 预算: 页写/回读节奏 + 擦除 + 三次断电复位 + 固定裕量; plan["budget_ms"] 可覆盖 (测试用)
711
+ budget_ms = int(plan.get("budget_ms") or (
712
+ pages * (int(plan.get("page_gap_ms", PAGE_WRITE_GAP_MS)) + 60)
713
+ + windows * (int(plan.get("read_gap_ms", PAGE_READ_GAP_MS)) + 40)
714
+ + int(plan.get("erase_ms", 2000))
715
+ + int(plan.get("off_ms", DEFAULT_OFF_MS)) * 3 + 30000))
716
+ t0 = time.monotonic()
717
+ proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
718
+ stderr=subprocess.PIPE, text=True, encoding="utf-8",
719
+ errors="replace", bufsize=1)
720
+ lines: list = []
721
+
722
+ def _reader() -> None:
723
+ try:
724
+ for raw in proc.stdout:
725
+ line = (raw or "").strip()
726
+ if not line:
727
+ continue
728
+ if line.startswith("#progress"):
729
+ print(f"[flash] {line[len('#progress'):].strip()}",
730
+ file=sys.stderr, flush=True)
731
+ continue
732
+ if line.startswith("{"):
733
+ lines.append(line)
734
+ except Exception:
735
+ pass
736
+
737
+ reader = threading.Thread(target=_reader, daemon=True)
738
+ reader.start()
739
+ try:
740
+ try:
741
+ proc.stdin.write(self._bridge_plan_json(plan))
742
+ proc.stdin.close()
743
+ except Exception as exc:
744
+ proc.kill()
745
+ raise NdtBoxError("UNKNOWN_ERROR", f"向桥 flash 写入计划失败: {exc}") from exc
746
+ deadline = time.monotonic() + max(5.0, budget_ms / 1000.0)
747
+ while time.monotonic() < deadline:
748
+ if proc.poll() is not None and not reader.is_alive():
749
+ break
750
+ time.sleep(0.05)
751
+ else:
752
+ proc.kill()
753
+ raise NdtBoxError(
754
+ "BUS_TIMEOUT",
755
+ f"桥内 flash 超时 (预算 {budget_ms}ms, {pages} 页/{windows} 窗): 已终止桥进程。"
756
+ "检查目标是否卡在擦除/页写 (可先 fw_ndtdbg_power_cycle 复位再试)",
757
+ None);
758
+ elapsed_ms = round((time.monotonic() - t0) * 1000)
759
+ if not lines:
760
+ err = ""
761
+ try:
762
+ err = (proc.stderr.read() or "")[-300:]
763
+ except Exception:
764
+ pass
765
+ raise NdtBoxError("UNKNOWN_ERROR",
766
+ f"桥内 flash 未返回 JSON 结果 (exit={proc.returncode})", err or None)
767
+ try:
768
+ body = json.loads(lines[-1])
769
+ except ValueError as exc:
770
+ raise NdtBoxError("UNKNOWN_ERROR", f"桥内 flash 结果解析失败: {exc}",
771
+ lines[-1][:200]) from exc
772
+ if not body.get("ok"):
773
+ cls = body.get("error_class") or "UNKNOWN_ERROR"
774
+ msg = str(body.get("error") or "桥内 flash 失败")
775
+ detail = body.get("detail")
776
+ extra: dict = {"engine": "bridge"}
777
+ m = re.search(r"@0x([0-9A-Fa-f]{4})", msg)
778
+ if m:
779
+ extra["first_mismatch_offset"] = int(m.group(1), 16)
780
+ mm = re.search(r"expect=([0-9A-Fa-f]*) got=([0-9A-Fa-f]*)", str(detail or ""))
781
+ if mm:
782
+ extra["first_mismatch_expect"] = mm.group(1).upper()
783
+ extra["first_mismatch_got"] = mm.group(2).upper()
784
+ raise NdtBoxError(cls, msg, detail, extra)
785
+ data = body.get("data") or {}
786
+ data["elapsed_ms"] = elapsed_ms
787
+ return data
788
+ finally:
789
+ try:
790
+ if proc.poll() is None:
791
+ proc.kill()
792
+ except Exception:
793
+ pass
794
+
795
+ def _flash_exec_bridge(self, plan: dict) -> dict:
796
+ """桥内单体 flash 引擎 (0.7.2 起为默认): 单进程内 USBIOX(0) 与 USB2IO(1) 句柄同持,
797
+
798
+ 帧经 stdin 一次性传入、全程连续无逐笔管道往返 —— 即原厂 GUI 的进程模型, 也是
799
+ 现场 (2026-09-11 F72) 6/6 verify pass 的那条路径。校验失败 (桥报
800
+ TARGET_MISMATCH "烧录校验失败") 时按 rewrite_times 整片重跑, 其余错误直接上抛。
801
+ """
802
+ import time
803
+ rewrite_times = max(0, int(plan.get("rewrite_times", _REWRITE_TIMES)))
804
+ t_all = time.monotonic()
805
+ attempts_detail: list = []
806
+ for attempt in range(rewrite_times + 1):
807
+ try:
808
+ data = self._bridge_flash_once(plan)
809
+ except NdtBoxError as exc:
810
+ attempts_detail.append({
811
+ "attempt": attempt + 1, "ok": False, "error_class": exc.error_class,
812
+ "first_mismatch_offset": exc.extra.get("first_mismatch_offset"),
813
+ "first_mismatch_expect": exc.extra.get("first_mismatch_expect"),
814
+ "first_mismatch_got": exc.extra.get("first_mismatch_got")})
815
+ if (exc.error_class == "TARGET_MISMATCH" and "校验失败" in exc.message
816
+ and attempt < rewrite_times):
817
+ continue # 整片重写 (桥内每次都是完整擦写)
818
+ raise NdtBoxError(
819
+ exc.error_class, exc.message, exc.detail,
820
+ {**exc.extra, "engine": "bridge", "attempts": attempt + 1,
821
+ "attempts_detail": attempts_detail,
822
+ "stage_timings": {"total_ms": round((time.monotonic() - t_all) * 1000)}})
823
+ attempts_detail.append({"attempt": attempt + 1, "ok": True,
824
+ "elapsed_ms": data.get("elapsed_ms"),
825
+ "verify": data.get("verify"),
826
+ "pages_written": data.get("pages_written")})
827
+ total_ms = round((time.monotonic() - t_all) * 1000)
828
+ return {
829
+ "verify": data.get("verify", "pass"),
830
+ "pages_written": data.get("pages_written"),
831
+ "read_windows": data.get("read_windows"),
832
+ "rewrite_count": attempt,
833
+ "attempts": attempt + 1,
834
+ "attempts_detail": attempts_detail,
835
+ "engine": "bridge",
836
+ "boot_judge": "n/a (bridge engine: 桥内无探测事务, 逐窗校验为唯一判据)",
837
+ "boot_evidence": "not_evaluated",
838
+ "probe_hex": None,
839
+ "probe_before_hex": None,
840
+ "off_ms": int(plan.get("off_ms", DEFAULT_OFF_MS)),
841
+ "stage_timings": {"total_ms": total_ms},
842
+ "total_ms": total_ms,
843
+ }
844
+ raise NdtBoxError("UNKNOWN_ERROR", "flash_exec(bridge) 未产出结果", None) # pragma: no cover
845
+
618
846
  def flash_exec(self, plan: dict, timeout_ms: int | None = None) -> dict:
847
+ """烧录引擎分派: "bridge" (默认, 桥内单体 flash) / "serve" (逐笔 Python 驱动)。
848
+
849
+ 现场实证 (2026-09-11, F72, 同一批固件 + 同一台设备):
850
+ 桥内单体 flash (帧经 stdin 一次传入) -> 6/6 {"ok":true,"verify":"pass"}
851
+ 逐笔 serve 路径 (每页一笔 raw 事务) -> 100% "擦除生效但页写不落地"
852
+ (probe FFFFFFFF -> 00000000 说明 7 字节擦除帧到达; 99 页 write_ms 7594ms
853
+ ≈ 77ms/页; 校验读回全 FF)
854
+ 两条路径的**帧完全相同** (build_page_write_frame, 命令行核对无误), 故差异在
855
+ 进程/传输模型而非帧组装 ⇒ 默认走桥内单体, serve 引擎保留供 A/B 复现与排查。
856
+ """
857
+ engine = str(plan.get("engine", "bridge") or "bridge").lower()
858
+ if engine == "bridge":
859
+ return self._flash_exec_bridge(plan)
860
+ if engine == "serve":
861
+ return self._flash_exec_serve(plan)
862
+ raise NdtBoxError("CONFIG_ERROR", "flash engine 非法 (bridge/serve)", engine)
863
+
864
+ def _flash_exec_serve(self, plan: dict, timeout_ms: int | None = None) -> dict:
619
865
  """Python 驱动整条 UpdateFW 序列 (0.4.0 serve 化): usb2io 断电复位进 boot +
620
866
  擦除 -> 页写 -> 回读校验 -> 完成魔数, 全部 I2C 事务经 serve 会话 (同进程同
621
867
  会话连续, 复刻原厂 GUI 进程模型)。校验 (逐窗 vs expect_hex) 在本模块完成,
622
- 失败 Fail-Closed 抛 TARGET_MISMATCH (信封带 stage_timings/probe_hex/first_mismatch_*)。
868
+ 并是 burn 成功与否的**权威判据**; 失败 Fail-Closed 抛 TARGET_MISMATCH
869
+ (信封带 stage_timings/probe_*/boot_evidence/first_mismatch_*)。
623
870
  校验不一致时按 rewrite_times 整片重写 (对齐 ChipSea.cs REWRITE_TIMES)。
624
871
 
625
872
  plan: {"off_ms":500,"erase_ms":2000,"page_gap_ms":30,"read_gap_ms":10,
626
- "rewrite_times":3,
873
+ "rewrite_times":3,"boot_judge":"diff",
627
874
  "writes":["<2B 地址+128B 数据帧 hex>",...],
628
875
  "windows":[{"off":0,"expect_hex":"<256B hex>"},...]}
629
876
 
630
- boot-in 判据 (F1, 2026-09-11 F72 实战修正): 擦除魔数 AA55A55A 是 boot ROM 专属
631
- 命令, App 不实现就只会忽略——而"运行态固件不应答 0x0000"的旧假设不成立
632
- (App 注册的静态寄存器 iic_test @0x00 会正常应答 25 01 18 01 02 05 00 01),
633
- 旧判据因此假阳, 擦除/378 页写全发给运行中的 App, 最后报误导性的 "校验失败
634
- @0x0000"。故判据后移到擦除 + erase_ms 之后读 0x0000: 全 FF (或读无应答) =
635
- ROM 已擦除; 读到任何非 FF 数据 = 仍在跟 App 说话 -> BOOT_NOT_ENTERED
636
- 时序随之与 GUI/桥内 flash 等价: vbrt -> 100ms(boot ready) -> 擦除魔数。
877
+ boot 判据 (0.7.1 差分判据; 详见 classify_boot_evidence):
878
+ 断电复位**前**读一次 0x0000 作为基线 (在关键窗口之外, 不影响"断电->擦除"间隔,
879
+ 时序仍与 GUI/桥内 flash 等价: vbrt -> 100ms(boot ready) -> 擦除魔数), 擦除
880
+ + erase_ms 后再读一次, 只在"两次是同一条非平凡应答"时判 BOOT_NOT_ENTERED;
881
+ 00/全 FF/任一侧无应答一律放行, 由页写后的逐窗校验兜底。boot_judge="off"
882
+ 完全关闭该判据 (仅记录证据, 不熔断)
637
883
  """
638
884
  import time
639
885
  off_ms = int(plan.get("off_ms", DEFAULT_OFF_MS))
@@ -641,6 +887,10 @@ class NdtBoxIo:
641
887
  page_gap = int(plan.get("page_gap_ms", PAGE_WRITE_GAP_MS))
642
888
  read_gap = int(plan.get("read_gap_ms", PAGE_READ_GAP_MS))
643
889
  rewrite_times = max(0, int(plan.get("rewrite_times", _REWRITE_TIMES)))
890
+ boot_judge = str(plan.get("boot_judge", "diff") or "diff").lower()
891
+ if boot_judge not in BOOT_JUDGE_MODES:
892
+ raise NdtBoxError("CONFIG_ERROR",
893
+ f"boot_judge 非法 (可选 {'/'.join(BOOT_JUDGE_MODES)})", boot_judge)
644
894
  pages = plan.get("writes", [])
645
895
  windows = plan.get("windows", [])
646
896
  t_all = time.monotonic()
@@ -648,9 +898,20 @@ class NdtBoxIo:
648
898
  timings = {"power_cycle_ms": 0, "boot_probe_ms": 0, "erase_ms": 0,
649
899
  "write_ms": 0, "verify_ms": 0}
650
900
  probe_hex: str | None = None
901
+ probe_before_hex: str | None = None
902
+ boot_evidence = "not_evaluated" if boot_judge == "off" else "inconclusive"
651
903
  rewrite_count = 0
652
904
  with self.session():
653
905
  for attempt in range(rewrite_times + 1):
906
+ # --- 0) 基線探测 (仅在首试判据开启时): 断电复位前采样 0x0000 ---
907
+ # 放在 vbrt 之前 => "断电 -> 擦除魔数" 窗口内不多任何事务 (与桥内
908
+ # 单体 flash/GUI 时序一致), 同时拿到"运行态固件应答长什么样"的基线。
909
+ if boot_judge == "diff" and attempt == 0:
910
+ t_stage = time.monotonic()
911
+ probe_before = self._probe_0000()
912
+ if probe_before:
913
+ probe_before_hex = probe_before.hex().upper()
914
+ timings["boot_probe_ms"] += round((time.monotonic() - t_stage) * 1000)
654
915
  # --- 1) 断电复位进 boot (IOoperation.VBRTOFF) ---
655
916
  t_stage = time.monotonic()
656
917
  self.vbrt_reset(off_ms)
@@ -668,6 +929,8 @@ class NdtBoxIo:
668
929
  "擦除命令超时无应答 (目标未进 boot 或总线挂死)。请仅对目标板断电重启 "
669
930
  "(勿动 BOX), 然后重新调用 fw_ndtdbg_flash", exc.detail,
670
931
  _flash_envelope(timings, probe_hex=probe_hex,
932
+ probe_before_hex=probe_before_hex,
933
+ boot_evidence=boot_evidence,
671
934
  rewrite_count=rewrite_count)) from exc
672
935
  raise NdtBoxError(
673
936
  "BOOT_NOT_ENTERED",
@@ -677,34 +940,38 @@ class NdtBoxIo:
677
940
  "独立看门狗芯片需要真断电)",
678
941
  exc.detail,
679
942
  _flash_envelope(timings, probe_hex=probe_hex,
943
+ probe_before_hex=probe_before_hex,
944
+ boot_evidence=boot_evidence,
680
945
  rewrite_count=rewrite_count)) from exc
681
946
  time.sleep(erase_ms / 1000.0)
682
947
  timings["erase_ms"] += round((time.monotonic() - t_stage) * 1000)
683
- # --- 3) boot 判据 (擦除后 0x0000 全 FF = ROM 已擦除; 非 FF = 还在跟 App 说话) ---
948
+ # --- 3) boot 判据 (差分: 只有"魔数前后同一条非平凡应答"才熔断) ---
684
949
  t_stage = time.monotonic()
685
- probe: bytes | None = None
686
- try:
687
- probe = self.raw_write_read(b"\x00\x00", 4)
688
- except NdtBoxError as exc:
689
- if exc.error_class == "BUS_TIMEOUT":
690
- raise # clock-stretch 挂死: 上抛 (信封已带断电指引)
691
- probe = None # 无应答: ROM 在 boot 窗口内的读协议不确定, 交给后续校验兜底
950
+ probe = self._probe_0000()
692
951
  timings["boot_probe_ms"] += round((time.monotonic() - t_stage) * 1000)
693
952
  if probe:
694
953
  probe_hex = probe.hex().upper()
695
- if probe and any(b != 0xFF for b in probe):
954
+ if boot_judge == "diff" and attempt == 0:
955
+ boot_evidence = classify_boot_evidence(
956
+ bytes.fromhex(probe_before_hex) if probe_before_hex else None, probe)
957
+ elif boot_judge == "off":
958
+ boot_evidence = "not_evaluated"
959
+ # 重写轮次不再判据 (基线已过期), 证据沿用首试结论
960
+ if boot_evidence == "app_still_answering":
696
961
  raise NdtBoxError(
697
962
  "BOOT_NOT_ENTERED",
698
- f"擦除后 0x0000 仍读回数据 ({probe_hex}): 目标未进入 boot ROM —— "
699
- "擦除魔数被忽略, 说明当前应答的是目标固件本身 (它在该地址注册了会应答本"
700
- "工具读协议的寄存器, 实测 iic_test[8] @0x00 = 25 01 18 01 02 05 00 01)。"
963
+ f"擦除魔数前后 0x0000 读回完全相同 ({probe_hex}): 擦除被忽略 目标未进入 "
964
+ "boot ROM (该命令只有 boot ROM 执行, 同一条应答说明应答者仍是运行态固件)。"
701
965
  "请仅对目标板断电重启 (勿动 BOX) 后重试; 若反复出现: ①确认 off_ms 足以构成"
702
966
  "真 POR (默认 500ms, 独立看门狗芯片只有上电/掉电/复位脚能清零); ②检查 "
703
967
  "usb2io IO10 供电复位接线; ③确认烧录的是本芯片 boot ROM 支持的协议; "
704
968
  "④若目标固件由你 (agent) 改动过, 先确认它确实使能了 I2C 从机 (从机未 init "
705
- "或提前 return 时该地址不应答, 现象会变成读回全 FF, 与掉电不可区分)",
969
+ "或提前 return 时该地址不应答, 现象会变成读回全 FF, 与掉电不可区分)"
970
+ "如仍要强制烧录: lab.yaml ndtdbg.flash.boot_judge: off / CLI --boot-judge off",
706
971
  None,
707
972
  _flash_envelope(timings, probe_hex=probe_hex,
973
+ probe_before_hex=probe_before_hex,
974
+ boot_evidence=boot_evidence,
708
975
  pages_written=0, rewrite_count=rewrite_count))
709
976
  # --- 4) 逐页写 (WriteFw) ---
710
977
  t_stage = time.monotonic()
@@ -739,11 +1006,17 @@ class NdtBoxIo:
739
1006
  if attempt < rewrite_times:
740
1007
  rewrite_count += 1 # 整片重写 (ChipSea.cs REWRITE_TIMES 语义)
741
1008
  continue
1009
+ hint = ("校验是烧录成功与否的权威判据 (非探测启发式): 若 probe 前后一致且为"
1010
+ "全 00/全 FF, 说明本次未进 boot 与'擦除生效'无法区分——请检查目标是否"
1011
+ "真的断电复位 (off_ms/IO10 接线)、以及目标固件是否使能了 I2C 从机; "
1012
+ "必要时可 --boot-judge off 关闭探测判据 (不改变校验)")
742
1013
  raise NdtBoxError(
743
1014
  "TARGET_MISMATCH",
744
1015
  f"烧录校验失败 @0x{mismatch['offset']:04X} (整片重写 {rewrite_count} 次后仍不一致)",
745
- f"expect={mismatch['expect']} got={mismatch['got']}",
1016
+ f"expect={mismatch['expect']} got={mismatch['got']}; {hint}",
746
1017
  _flash_envelope(timings, probe_hex=probe_hex,
1018
+ probe_before_hex=probe_before_hex,
1019
+ boot_evidence=boot_evidence,
747
1020
  pages_written=len(pages),
748
1021
  pages_written_total=len(pages) * (rewrite_count + 1),
749
1022
  rewrite_count=rewrite_count,
@@ -759,7 +1032,9 @@ class NdtBoxIo:
759
1032
  timings["total_ms"] = round((time.monotonic() - t_all) * 1000)
760
1033
  out = {"verify": "pass", "pages_written": len(pages),
761
1034
  "read_windows": len(windows), "rewrite_count": rewrite_count,
762
- "attempts": rewrite_count + 1, "probe_hex": probe_hex,
1035
+ "attempts": rewrite_count + 1,
1036
+ "probe_hex": probe_hex, "probe_before_hex": probe_before_hex,
1037
+ "boot_evidence": boot_evidence, "boot_judge": boot_judge,
763
1038
  "off_ms": off_ms, "stage_timings": timings}
764
1039
  # 兼容 0.6.x 的扁平键 (P3 分阶段耗时): 由 stage_timings 派生, 单一来源
765
1040
  out.update({"reset_ms": timings["power_cycle_ms"],
@@ -1118,21 +1393,23 @@ class ChipSeaFxx:
1118
1393
 
1119
1394
  def flash_bin(self, file_path: str, erase_ms: int = 2000,
1120
1395
  off_ms: int | None = None, rewrite_times: int | None = None,
1396
+ boot_judge: str | None = None, engine: str | None = None,
1121
1397
  progress=None) -> dict:
1122
1398
  """经 NdtBox IIC 烧录 .bin/.nfw 固件到芯海 Fxx (UpdateFW 语义)。
1123
1399
 
1124
1400
  时序逐行对齐 ChipSea.cs UpdateFW, 进程模型对齐原厂 STDataCollection GUI:
1125
- VBRTOFF(off_ms) 断电复位进 boot -> 100ms -> 擦除魔数 -> erase_ms ->
1126
- 擦除后 0x0000 判据 (F1) -> 128B 页写(30ms) -> 200ms ->
1127
- 256B 窗回读校验(10ms) -> 不一致整片重写 (<= rewrite_times) ->
1128
- 完成魔数 "PASs" -> VBRTOFF(off_ms) 复位加载。
1401
+ [判据基线探测 0x0000 (在窗口之外)] -> VBRTOFF(off_ms) 断电复位进 boot
1402
+ -> 100ms -> 擦除魔数 -> erase_ms -> 差分 boot 判据 ->
1403
+ 128B 页写(30ms) -> 200ms -> 逐 256B 窗回读校验(10ms, 权威判据)
1404
+ -> 不一致整片重写 (<= rewrite_times) -> 完成魔数 "PASs" -> VBRTOFF 复位加载。
1129
1405
  全程经同一 serve 会话连续执行 (同进程同 USB 会话), ~15s 量级。
1130
1406
  帧组装在本层 (可单测); 校验失败 Fail-Closed 抛 NdtBoxError, 信封带
1131
- stage_timings/probe_hex/first_mismatch_* (P1-2)。
1407
+ stage_timings/probe_before_hex/probe_hex/boot_evidence/first_mismatch_* (P1-2)。
1132
1408
 
1133
1409
  off_ms 默认 DEFAULT_OFF_MS=500 (独立看门狗芯片需真 POR); rewrite_times
1134
- 默认 _REWRITE_TIMES=3 (对齐 ChipSea.cs)。progress(phase, done, total)
1135
- verify 阶段末回调。
1410
+ 默认 _REWRITE_TIMES=3 (对齐 ChipSea.cs); boot_judge 默认 "diff" (差分判据,
1411
+ 只在有正面证据表明应答者仍是运行态固件时熔断; "off" = 完全关闭, 仅记录证据)。
1412
+ progress(phase, done, total) 在 verify 阶段末回调。
1136
1413
  """
1137
1414
  import time
1138
1415
  fw_type, fw = load_firmware(file_path)
@@ -1145,6 +1422,8 @@ class ChipSeaFxx:
1145
1422
  "page_gap_ms": PAGE_WRITE_GAP_MS,
1146
1423
  "read_gap_ms": PAGE_READ_GAP_MS,
1147
1424
  "rewrite_times": _REWRITE_TIMES if rewrite_times is None else max(0, int(rewrite_times)),
1425
+ "boot_judge": str(boot_judge or "diff").lower(),
1426
+ "engine": str(engine or "bridge").lower(),
1148
1427
  "writes": [build_page_write_frame(off, fw[off:off + FLASH_WRITE_PAGE]).hex()
1149
1428
  for off in pages],
1150
1429
  "windows": [{"off": off, "expect_hex": expected_read_window(fw, off).hex()}
@@ -1162,8 +1441,14 @@ class ChipSeaFxx:
1162
1441
  "erase_ms": int(erase_ms),
1163
1442
  "off_ms": plan["off_ms"],
1164
1443
  "rewrite_times": plan["rewrite_times"],
1444
+ "engine": res.get("engine", plan["engine"]),
1445
+ "boot_judge": res.get("boot_judge", plan["boot_judge"]),
1446
+ "boot_evidence": res.get("boot_evidence"),
1447
+ "probe_hex": res.get("probe_hex"),
1448
+ "probe_before_hex": res.get("probe_before_hex"),
1165
1449
  "rewrite_count": res.get("rewrite_count", 0),
1166
1450
  "attempts": res.get("attempts", 1),
1451
+ "attempts_detail": res.get("attempts_detail"),
1167
1452
  "stage_timings": res.get("stage_timings"),
1168
1453
  "verify": res.get("verify", "pass"),
1169
1454
  "duration_ms": int((time.monotonic() - started) * 1000),
@@ -994,6 +994,49 @@ class SerialSession:
994
994
  return out
995
995
 
996
996
 
997
+ _PORT_HOG_HINTS = ("stdatacollection", "ndtdebug", "ndt debug", "serialassistant", "sscom",
998
+ "comassistant", "putty", "xshell", "securecrt", "minicom", "logic", "saleae")
999
+
1000
+
1001
+ def suspected_port_holders(session_pid: int | None = None) -> list[dict]:
1002
+ """列出可能占着串口的本机进程 (tasklist 扫描, best-effort; 失败返回空列表)。
1003
+
1004
+ Windows 没有便宜的"谁持有 COMx"API, 但现场 90% 的 PermissionError(13) 都是
1005
+ NDT Debug Tool GUI (STDataCollection.exe) 或残留的 fwloop serve 会话 —— 直接给
1006
+ 出嫌疑名单比只报一句 PermissionError 更有用。session_pid 用于把"本会话 serve"
1007
+ 与"其它 python (很可能是 MCP server, 勿盲杀)"区分开。
1008
+ """
1009
+ out: list[dict] = []
1010
+ if os.name != "nt":
1011
+ return out
1012
+ try:
1013
+ res = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True,
1014
+ text=True, timeout=15, errors="replace")
1015
+ except Exception: # noqa: BLE001
1016
+ return out
1017
+ self_pid = os.getpid()
1018
+ for line in (res.stdout or "").splitlines():
1019
+ cols = [c.strip().strip('"') for c in line.split('","')]
1020
+ if len(cols) < 2:
1021
+ continue
1022
+ name, pid_s = cols[0], cols[1]
1023
+ try:
1024
+ pid = int(pid_s)
1025
+ except ValueError:
1026
+ continue
1027
+ low = name.lower()
1028
+ if any(k in low for k in _PORT_HOG_HINTS):
1029
+ out.append({"name": name, "pid": pid,
1030
+ "why": "GUI/上位机软件可能独占串口 (关闭后再重试)"})
1031
+ elif low.startswith("python") and pid not in (self_pid, session_pid):
1032
+ out.append({"name": name, "pid": pid,
1033
+ "why": "python 进程 (可能是残留 serve 会话; 若为 MCP server 请勿终止)"})
1034
+ if session_pid:
1035
+ out = [h for h in out if h["pid"] != session_pid]
1036
+ out.append({"name": "(本会话 serve)", "pid": session_pid, "why": "正常: fw_serial_close 会回收"})
1037
+ return out
1038
+
1039
+
997
1040
  def pid_alive(pid: int | None) -> bool:
998
1041
  """进程存活探测。
999
1042
 
@@ -249,9 +249,16 @@ def cmd_status(io: NdtBoxIo) -> None:
249
249
  # P2-1 分层判据表: 把"全 FF/无应答"的几种成因摊开, 避免一句 hint 把人带偏
250
250
  data["diagnosis_table"] = [
251
251
  {"symptom": "能读到任一已知寄存器", "likely": "从机在跑, 链路正常 (判活以此为准)"},
252
- {"symptom": "读回全 FF/FE、无异常", "likely": "从机未使能 / 目标掉电 / 从机地址不符"},
252
+ {"symptom": "读回全 FF/FE、无异常",
253
+ "likely": "①目标固件未使能 I2C 从机 (最常见, 与掉电现象一致) ②目标掉电 ③从机地址不符"},
253
254
  {"symptom": "TARGET_MISMATCH: IIC 事务无应答",
254
255
  "likely": "地址未被 ACK (外设未使能/复位中/芯片未上电)"},
256
+ {"symptom": "只有写事务 NACK, 读事务正常",
257
+ "likely": "不是从机死: 该寄存器不可写/写保护, 或目标固件未实现该写寄存器 "
258
+ "(此时应回读邻近寄存器确认链路仍活)"},
259
+ {"symptom": "原本可读, 新增静态寄存器地址后整机读不到",
260
+ "likely": "新增地址破坏了从机寄存器表约定 (必须按 addr 升序且不与擦除魔数 0x0000 "
261
+ "冲突) ⇒ 建议优先复用已有寄存器的空闲字, 避免新增地址"},
255
262
  {"symptom": "BUS_TIMEOUT", "likely": "时钟拉伸、关中断死循环 (fault handler 自旋饿死 I2C 中断)"},
256
263
  ]
257
264
  ok("status", data, log="Open + ReadFwVersion(0x10) + ReadModuleConnect(0x29)")
@@ -288,9 +295,15 @@ def cmd_read_reg(io: NdtBoxIo, reg: int, length: int, retry: int = 0) -> None:
288
295
  "io_ms": round((time.monotonic() - t0) * 1000, 1),
289
296
  "retries": attempts - 1}
290
297
  if data and all(b in (0xFF, 0xFE) for b in data):
291
- # 总线无应答时上拉/悬空读回默认电平, 不能当作真实寄存器值误导调用方
292
- body["warning"] = ("读回全部 0xFF/0xFE: 疑似 IIC 目标无应答 (总线悬空默认电平), "
293
- "请检查接线/上拉/供电/从机地址")
298
+ # 总线无应答时上拉/悬空读回默认电平, 不能当作真实寄存器值误导调用方。
299
+ # P2-1: 第一候选是"目标固件没使能从机"(现象与掉电完全一样), 而不是硬件掉了
300
+ body["warning"] = ("读回全部 0xFF/0xFE: 疑似 IIC 目标无应答。第一候选是【目标固件"
301
+ "根本没使能 I2C 从机】(从机表初始化提前 return / 未注册), 现象与掉电"
302
+ "完全一样; 其次才是掉电/接线/上拉/从机地址。可参考 fw_ndtdbg_status "
303
+ "的 diagnosis_table 逐条排除")
304
+ body["candidates"] = ["目标固件未使能 I2C 从机 (最常见, 现象与掉电一致)",
305
+ "目标掉电 / 复位中", "接线/上拉/供电异常",
306
+ "从机地址不符 (lab.yaml ndtdbg.slave_addr)"]
294
307
  ok("read-reg", body, log=f"ReadReg(0x{reg:02X}, {length})")
295
308
 
296
309
 
@@ -504,7 +517,8 @@ def cmd_stream(io: NdtBoxIo, config: dict, specs: list, interval_ms, duration_s:
504
517
 
505
518
 
506
519
  def cmd_flash_bin(io: NdtBoxIo, config: dict, file_path: str, erase_ms: int, confirm: bool,
507
- off_ms: int | None = None, rewrite_times: int | None = None) -> None:
520
+ off_ms: int | None = None, rewrite_times: int | None = None,
521
+ boot_judge: str | None = None, engine: str | None = None) -> None:
508
522
  # 烧录 = 整片擦除 + 页写, 破坏性高于 write-reg, 同款双闸门
509
523
  ndt_cfg = config.get("ndtdbg") or {}
510
524
  allowed = ndt_cfg.get("write_allowed", False)
@@ -516,22 +530,30 @@ def cmd_flash_bin(io: NdtBoxIo, config: dict, file_path: str, erase_ms: int, con
516
530
  f"生效配置: {effective_config_desc()}"))
517
531
  # P1-1: off_ms 可配置 (CLI --off-ms > lab.yaml ndtdbg.flash.off_ms > 内置 500ms)。
518
532
  # 独立看门狗芯片 (如 CSA37F72 IWDG_S) 只有真 POR 能清零, 200ms 可能只是深度掉压。
533
+ flash_cfg = ndt_cfg.get("flash") or {}
519
534
  if off_ms is None:
520
- flash_cfg = ndt_cfg.get("flash") or {}
521
535
  off_ms = flash_cfg.get("off_ms")
536
+ # 0.7.1: boot 判据可整体关闭 (差分判据仍是启发式; 现场遇到与本 ROM 行为不符的
537
+ # 目标时, 用 off 让页写后的逐窗校验做唯一判据 —— 即桥内单体 flash 的语义)
538
+ if boot_judge is None:
539
+ boot_judge = flash_cfg.get("boot_judge")
540
+ # 0.7.2: 烧录引擎 (bridge 默认 = 桥内单体 flash; serve = 逐笔 Python 驱动, 供 A/B 复现)
541
+ if engine is None:
542
+ engine = flash_cfg.get("engine")
522
543
 
523
544
  def progress(phase: str, done: int, total: int) -> None:
524
545
  print(f"[flash-bin] {phase} {done}/{total}", file=sys.stderr, flush=True)
525
546
 
526
547
  result = ChipSeaFxx(io).flash_bin(file_path, erase_ms=erase_ms, off_ms=off_ms,
527
- rewrite_times=rewrite_times, progress=progress)
548
+ rewrite_times=rewrite_times, boot_judge=boot_judge,
549
+ engine=engine, progress=progress)
528
550
  result["authorization"] = {"write_allowed": True,
529
551
  "granted_by": write_allowed_source() or "(default false)",
530
552
  "chain": list(_CONFIG_FILES)}
531
553
  ok("flash-bin", result,
532
554
  log=f"UpdateFW({file_path}): {result['fw_len']}B, {result['pages_written']} pages, "
533
- f"off_ms={result.get('off_ms')}, rewrite={result.get('rewrite_count', 0)}, "
534
- f"verify={result['verify']}")
555
+ f"engine={result.get('engine')}, off_ms={result.get('off_ms')}, "
556
+ f"rewrite={result.get('rewrite_count', 0)}, verify={result['verify']}")
535
557
 
536
558
 
537
559
  # --------------------------------------------------------------------------- main
@@ -582,6 +604,13 @@ def build_parser() -> argparse.ArgumentParser:
582
604
  "独立看门狗芯片需要真 POR)")
583
605
  f.add_argument("--rewrite-times", type=int, default=None,
584
606
  help="校验不一致时整片重写次数上限 (默认对齐 ChipSea.cs 的 3; 0=不重写)")
607
+ f.add_argument("--boot-judge", choices=["diff", "off"], default=None,
608
+ help="boot 判据 (默认 diff: 只有'擦除魔数前后同一条非平凡应答'才熔断; "
609
+ "off: 完全关闭探测判据, 仅记录证据, 由逐窗校验做唯一判据); "
610
+ "仅 serve 引擎适用")
611
+ f.add_argument("--flash-engine", choices=["bridge", "serve"], default=None,
612
+ help="烧录引擎 (默认 bridge = 桥内单体 flash: 单进程、帧经 stdin 一次传入、"
613
+ "现场 6/6 verify pass; serve = 逐笔 raw 事务的 Python 驱动, 供 A/B 复现)")
585
614
 
586
615
  st = sub.add_parser("stream", parents=[common],
587
616
  help="高速连续采集寄存器组到 CSV (单会话长驻, 只读)")
@@ -641,7 +670,8 @@ def main(argv=None) -> None:
641
670
  cmd_poll(io, args.duration_ms, args.interval_ms, args.max_frames, args.full)
642
671
  elif args.cmd == "flash-bin":
643
672
  cmd_flash_bin(io, config, args.file, args.erase_ms, args.confirm,
644
- off_ms=args.off_ms, rewrite_times=args.rewrite_times)
673
+ off_ms=args.off_ms, rewrite_times=args.rewrite_times,
674
+ boot_judge=args.boot_judge, engine=args.flash_engine)
645
675
  elif args.cmd == "stream":
646
676
  specs = [parse_reg_spec(s) for s in args.regs]
647
677
  cmd_stream(io, config, specs, args.interval_ms, args.duration_s,
@@ -36,6 +36,7 @@ from lib.serial_assistant import ( # noqa: E402
36
36
  parse_hex,
37
37
  probe_session,
38
38
  to_hex,
39
+ suspected_port_holders,
39
40
  wait_pid_exit,
40
41
  )
41
42
 
@@ -122,6 +123,8 @@ def cmd_status(_args: argparse.Namespace) -> None:
122
123
  "started_at": stats.get("started_at")},
123
124
  "conclusion": f"会话存活 (port={sess.get('port') or stats.get('port')}, "
124
125
  f"opened={alive})" if alive else "serve 进程存活但串口未打开",
126
+ # P1-4: 串口被谁占着 —— 直接给嫌疑名单 (GUI/上位机 + 残留 python serve)
127
+ "suspected_holders": suspected_port_holders(sess.get("pid")),
125
128
  })
126
129
 
127
130
 
@@ -149,6 +152,7 @@ def cmd_open(args: argparse.Namespace) -> None:
149
152
  "holder_hint",
150
153
  "端口可能被 GUI 上位机占用: NDT Debug Tool (STDataCollection.exe) 打开时会同时占用 "
151
154
  "Box 串口与 USB-IIC 通道; 请关闭占用方后重试, 残留会话可用 fw_serial_close 清理")
155
+ extra.setdefault("suspected_holders", suspected_port_holders())
152
156
  fail(exc.error_class, exc.detail, extra=extra)
153
157
  data = {k: info.get(k) for k in
154
158
  ("port", "baud", "simulated", "log_path", "reused", "pid", "started_at")}
@@ -212,7 +216,7 @@ def cmd_send(args: argparse.Namespace) -> None:
212
216
  time.sleep(delay / 1000.0)
213
217
  stats = session.stats()
214
218
  except SerialAssistantError as exc:
215
- fail(exc.error_class, exc.detail, extra=exc.extra)
219
+ fail(exc.error_class, exc.detail, extra=_port_error_extra(exc))
216
220
  ok("send", {"frames_sent": sent, "frames_defined": len(frames),
217
221
  "elapsed_ms": round((time.monotonic() - t0) * 1000, 2),
218
222
  "tx_bytes": stats.get("tx_bytes"), "rx_bytes": stats.get("rx_bytes")},
@@ -229,7 +233,7 @@ def cmd_read(args: argparse.Namespace) -> None:
229
233
  else:
230
234
  result = session.read(max_bytes=RX_BUFFER_LIMIT if args.drain else args.max_bytes)
231
235
  except SerialAssistantError as exc:
232
- fail(exc.error_class, exc.detail, extra=exc.extra)
236
+ fail(exc.error_class, exc.detail, extra=_port_error_extra(exc))
233
237
 
234
238
  if args.drain and not args.expect:
235
239
  # P2-3 (0909 提案): 单次排空整个缓冲; 大 payload 落盘而非进 MCP 响应,
@@ -321,7 +325,7 @@ def cmd_parse(args: argparse.Namespace) -> None:
321
325
  frames = parser.feed(data_bytes)
322
326
  frames += parser.finalize()
323
327
  except SerialAssistantError as exc:
324
- fail(exc.error_class, exc.detail, extra=exc.extra)
328
+ fail(exc.error_class, exc.detail, extra=_port_error_extra(exc))
325
329
  valid_n = sum(1 for f in frames if f["valid"])
326
330
  ok("parse", {"source": src, "frames_n": len(frames), "valid_n": valid_n,
327
331
  "invalid_n": len(frames) - valid_n, "bytes": len(data_bytes),
@@ -329,6 +333,14 @@ def cmd_parse(args: argparse.Namespace) -> None:
329
333
  log=f"SerialParse({src}): {len(frames)} frames, {valid_n} valid")
330
334
 
331
335
 
336
+ def _port_error_extra(exc: SerialAssistantError) -> dict:
337
+ """串口类错误的统一附加信息: 嫌疑占用进程名单 (P1-4)。"""
338
+ extra = dict(exc.extra)
339
+ if exc.error_class in ("SERIAL_PORT_ERROR", "UART_BUSY"):
340
+ extra.setdefault("suspected_holders", suspected_port_holders())
341
+ return extra
342
+
343
+
332
344
  def _run_power_cycle(off_ms: int) -> dict:
333
345
  """P2-4: 触发一次 NdtBox 断电复位 (复用 ndtdbg_cli power-cycle, 同 lab.yaml 解析链)。"""
334
346
  cli = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ndtdbg_cli.py")
@@ -389,9 +401,15 @@ def cmd_capture(args: argparse.Namespace) -> None:
389
401
  result = box.get("r") or {}
390
402
  stats = session.stats()
391
403
  except SerialAssistantError as exc:
392
- fail(exc.error_class, exc.detail, extra=exc.extra)
404
+ fail(exc.error_class, exc.detail, extra=_port_error_extra(exc))
393
405
  payload = {**result, "tx_bytes": stats.get("tx_bytes"),
394
406
  "simulated": stats.get("simulated")}
407
+ if args.duration_ms >= 10000:
408
+ # P2: MCP stdio 循环单线程 —— 长调用会让同会话的其它调用排队, 客户端可能先超时
409
+ payload["blocking_notice"] = (
410
+ f"服务端本次阻塞 {args.duration_ms}ms: MCP stdio 是单线程的, 同一会话内其它工具"
411
+ "调用会排在它后面 (客户端超时往往先到)。长采集建议缩短窗口, 或用 ndtdbg stream "
412
+ "job 模式 (fw_ndtdbg_stream_start/status/stop) 让采集不占调用通道")
395
413
  if power_off_ms is not None:
396
414
  payload["power_cycle"] = pc_result
397
415
  if not (pc_result or {}).get("ok"):
@@ -498,7 +516,7 @@ def main() -> None:
498
516
  try:
499
517
  handler(args)
500
518
  except SerialAssistantError as exc:
501
- fail(exc.error_class, exc.detail, extra=exc.extra)
519
+ fail(exc.error_class, exc.detail, extra=_port_error_extra(exc))
502
520
 
503
521
 
504
522
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  <#
2
2
  .SYNOPSIS
3
- Dual-Tier MCP setup and registration helper (v0.7.0).
3
+ Dual-Tier MCP setup and registration helper (v0.7.2).
4
4
  Inspects installed AI coding agents (Qoder, Claude Code, Antigravity, Cursor)
5
5
  and configures global MCP / skills or prints exact registration commands.
6
6
 
@@ -242,7 +242,7 @@ if ($Json) {
242
242
  Write-FwJson ([pscustomobject]$report)
243
243
  } else {
244
244
  Write-Host "============================================================" -ForegroundColor Cyan
245
- Write-Host " FirmwareLoop Dual-Tier MCP & Agent Setup Helper (v0.7.0)" -ForegroundColor Green
245
+ Write-Host " FirmwareLoop Dual-Tier MCP & Agent Setup Helper (v0.7.2)" -ForegroundColor Green
246
246
  Write-Host "============================================================" -ForegroundColor Cyan
247
247
  Write-Host ""
248
248
  Write-Host "[1] Google Antigravity / Gemini CLI:" -ForegroundColor Yellow
File without changes
File without changes
File without changes