firmwareloop 0.1.9__tar.gz → 0.2.1__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 (35) hide show
  1. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/PKG-INFO +3 -3
  2. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/README.md +2 -2
  3. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/firmwareloop.egg-info/PKG-INFO +3 -3
  4. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/pyproject.toml +1 -1
  5. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/__init__.py +1 -1
  6. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/fw_mcp_server.py +159 -18
  7. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/lib/ndtdbg.py +122 -0
  8. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/ndtdbg_bridge.ps1 +121 -1
  9. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/ndtdbg_cli.py +134 -1
  10. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/setup-agent-mcp.ps1 +2 -2
  11. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/LICENSE +0 -0
  12. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/firmwareloop.egg-info/SOURCES.txt +0 -0
  13. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/firmwareloop.egg-info/dependency_links.txt +0 -0
  14. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/firmwareloop.egg-info/entry_points.txt +0 -0
  15. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/firmwareloop.egg-info/requires.txt +0 -0
  16. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/firmwareloop.egg-info/top_level.txt +0 -0
  17. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/setup.cfg +0 -0
  18. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/acceptance-scenario.ps1 +0 -0
  19. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/build.ps1 +0 -0
  20. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/can.ps1 +0 -0
  21. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/check-qoder-mcp.ps1 +0 -0
  22. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/common/build-backends.psm1 +0 -0
  23. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/common/fw.psm1 +0 -0
  24. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/common/uart_probe.py +0 -0
  25. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/doctor.ps1 +0 -0
  26. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/flash.ps1 +0 -0
  27. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/instrument_cli.py +0 -0
  28. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/lib/__init__.py +0 -0
  29. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/lib/instruments.py +0 -0
  30. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/logic_capture.ps1 +0 -0
  31. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/logic_decode.ps1 +0 -0
  32. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/reset.ps1 +0 -0
  33. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/sigrok_csv_to_fwloop.py +0 -0
  34. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/test-backends.ps1 +0 -0
  35. {firmwareloop-0.1.9 → firmwareloop-0.2.1}/tools/test.ps1 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firmwareloop
3
- Version: 0.1.9
3
+ Version: 0.2.1
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
@@ -381,7 +381,7 @@ claude mcp remove --scope user agentic-hil
381
381
  ├── demo-firmware/ 示例固件(CMake;宿主编译模拟 MCU,闭环验证载体)
382
382
  ├── demo-make/ 示例固件(Make;构建测试载体)
383
383
  ├── docs/ DEPENDENCY_MATRIX / REUSE_PLAN / V0.0.2_GAP_VERIFICATION
384
- ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.1.9)
384
+ ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.2.1)
385
385
  ├── .mcp.example.json 双层 MCP 配置模板(firmwareloop + agentic-hil)
386
386
  ├── AGENTS.md 通用智能体规范(Antigravity / Qoder / Cursor 等)
387
387
  ├── CLAUDE.md Claude Code CLI 指南
@@ -391,7 +391,7 @@ claude mcp remove --scope user agentic-hil
391
391
 
392
392
  ---
393
393
 
394
- ## 能力验证状态(v0.1.9)
394
+ ## 能力验证状态(v0.2.1)
395
395
 
396
396
  > 状态定义:`Implemented`(已实现)/ `Simulator Validated`(模拟验证)/
397
397
  > `Real Hardware Validated`(真机验证)/ `Experimental` / `Not Implemented`
@@ -353,7 +353,7 @@ claude mcp remove --scope user agentic-hil
353
353
  ├── demo-firmware/ 示例固件(CMake;宿主编译模拟 MCU,闭环验证载体)
354
354
  ├── demo-make/ 示例固件(Make;构建测试载体)
355
355
  ├── docs/ DEPENDENCY_MATRIX / REUSE_PLAN / V0.0.2_GAP_VERIFICATION
356
- ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.1.9)
356
+ ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.2.1)
357
357
  ├── .mcp.example.json 双层 MCP 配置模板(firmwareloop + agentic-hil)
358
358
  ├── AGENTS.md 通用智能体规范(Antigravity / Qoder / Cursor 等)
359
359
  ├── CLAUDE.md Claude Code CLI 指南
@@ -363,7 +363,7 @@ claude mcp remove --scope user agentic-hil
363
363
 
364
364
  ---
365
365
 
366
- ## 能力验证状态(v0.1.9)
366
+ ## 能力验证状态(v0.2.1)
367
367
 
368
368
  > 状态定义:`Implemented`(已实现)/ `Simulator Validated`(模拟验证)/
369
369
  > `Real Hardware Validated`(真机验证)/ `Experimental` / `Not Implemented`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firmwareloop
3
- Version: 0.1.9
3
+ Version: 0.2.1
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
@@ -381,7 +381,7 @@ claude mcp remove --scope user agentic-hil
381
381
  ├── demo-firmware/ 示例固件(CMake;宿主编译模拟 MCU,闭环验证载体)
382
382
  ├── demo-make/ 示例固件(Make;构建测试载体)
383
383
  ├── docs/ DEPENDENCY_MATRIX / REUSE_PLAN / V0.0.2_GAP_VERIFICATION
384
- ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.1.9)
384
+ ├── pyproject.toml 标准 Python 包配置与 CLI 入口声明 (v0.2.1)
385
385
  ├── .mcp.example.json 双层 MCP 配置模板(firmwareloop + agentic-hil)
386
386
  ├── AGENTS.md 通用智能体规范(Antigravity / Qoder / Cursor 等)
387
387
  ├── CLAUDE.md Claude Code CLI 指南
@@ -391,7 +391,7 @@ claude mcp remove --scope user agentic-hil
391
391
 
392
392
  ---
393
393
 
394
- ## 能力验证状态(v0.1.9)
394
+ ## 能力验证状态(v0.2.1)
395
395
 
396
396
  > 状态定义:`Implemented`(已实现)/ `Simulator Validated`(模拟验证)/
397
397
  > `Real Hardware Validated`(真机验证)/ `Experimental` / `Not Implemented`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "firmwareloop"
7
- version = "0.1.9"
7
+ version = "0.2.1"
8
8
  description = "AI Agent Firmware Development and Lab Automation Platform (Dual-Tier MCP Server)"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -2,4 +2,4 @@
2
2
  FirmwareLoop - AI Agent Firmware Development and Lab Automation Platform.
3
3
  """
4
4
 
5
- __version__ = "0.1.9"
5
+ __version__ = "0.2.1"
@@ -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.1.9).
3
+ fw_mcp_server.py - FirmwareLoop High-Level Workflow & Device MCP Server (v0.2.1).
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
@@ -20,6 +20,7 @@ Tools provided:
20
20
  - fw_ndtdbg_write_reg: NdtBox IIC register write, double-gated (ndtdbg_cli.py)
21
21
  - fw_ndtdbg_capture_seq: NdtBox register sequence + concurrent I2C capture for waveform evidence (ndtdbg_cli.py)
22
22
  - fw_ndtdbg_flash: NdtBox IIC firmware flash (.bin/.nfw, full-chip erase, double-gated) (ndtdbg_cli.py)
23
+ - fw_ndtdbg_stream: NdtBox high-rate register stream capture to CSV via one long-lived USB session (read-only) (ndtdbg_cli.py)
23
24
  - fw_logic_capture: Logic analyzer protocol capture (logic_capture.ps1)
24
25
  - fw_logic_decode: Logic analyzer protocol decoding & assertion (logic_decode.ps1)
25
26
  - fw_get_evidence: Read structured audit run evidence (artifacts/runs/)
@@ -330,6 +331,39 @@ def handle_fw_ndtdbg_flash(args: Dict[str, Any]) -> Dict[str, Any]:
330
331
  return _ndtdbg_run(cmd, timeout_s)
331
332
 
332
333
 
334
+ def handle_fw_ndtdbg_stream(args: Dict[str, Any]) -> Dict[str, Any]:
335
+ """Stream-read a set of IIC registers at a fixed interval via one long-lived
336
+ USB session (sampling loop runs inside the 32-bit bridge process, so the
337
+ per-frame overhead is one StreamI2C transaction, not a full cold start).
338
+ Read-only. Writes CSV with timestamps; returns JSON summary."""
339
+ cli_script = os.path.join(REPO_ROOT, "tools", "ndtdbg_cli.py")
340
+ regs = args.get("regs") or ["0xEE:8"]
341
+ duration_s = min(max(float(args.get("duration_s", 10)), 0.0), 600.0)
342
+ max_frames = int(args.get("max_frames", 0))
343
+ if duration_s <= 0 and max_frames <= 0:
344
+ return {"ok": False, "error_class": "CONFIG_ERROR",
345
+ "error": "stream requires duration_s or max_frames > 0"}
346
+ cmd = [PYTHON_EXE, cli_script, "stream", "--regs", *[str(r) for r in regs]]
347
+ if args.get("interval_ms") is not None:
348
+ cmd.extend(["--interval-ms", str(int(args["interval_ms"]))])
349
+ cmd.extend(["--duration-s", str(duration_s)])
350
+ if max_frames > 0:
351
+ cmd.extend(["--max-frames", str(max_frames)])
352
+ if args.get("out_path"):
353
+ cmd.extend(["--out", str(args["out_path"])])
354
+ decode = args.get("decode", "hex")
355
+ if decode != "hex":
356
+ cmd.extend(["--decode", decode])
357
+ if args.get("retry") is not None:
358
+ cmd.extend(["--retry", str(int(args["retry"]))])
359
+ if args.get("fail_threshold") is not None:
360
+ cmd.extend(["--fail-threshold", str(int(args["fail_threshold"]))])
361
+ if args.get("require_target"):
362
+ cmd.append("--require-target")
363
+ _ndtdbg_reg_args(args, cmd)
364
+ return _ndtdbg_run(cmd, timeout=int(duration_s) + 120)
365
+
366
+
333
367
  def handle_fw_ndtdbg_capture_seq(args: Dict[str, Any]) -> Dict[str, Any]:
334
368
  """单次调用内并发: 逻辑分析仪抓取与 NdtBox 寄存器序列同时进行。
335
369
 
@@ -1352,6 +1386,65 @@ TOOLS_REGISTRY = {
1352
1386
  },
1353
1387
  "handler": handle_fw_ndtdbg_flash,
1354
1388
  },
1389
+ "fw_ndtdbg_stream": {
1390
+ "description": "Stream-read a set of IIC registers from the NdtBox target chip (ChipSea Fxx) at a fixed interval via one long-lived USB session. Read-only. Writes CSV with timestamps; returns JSON summary (frames, FF_GAP/IO_FAIL counts, interval stats). For capturing sensor/debug data streams (e.g. PT488 0xEE 4-channel data). Typical combo: write_reg(0xBE=01, confirm) to start debug_mode, then stream(0xEE).",
1391
+ "inputSchema": {
1392
+ "type": "object",
1393
+ "properties": {
1394
+ "regs": {
1395
+ "type": "array",
1396
+ "items": {"type": "string"},
1397
+ "description": "Register specs 'addr:len[:name]', e.g. ['0xEE:8:ch', '0x20:138:event']. Default ['0xEE:8'].",
1398
+ "default": ["0xEE:8"],
1399
+ },
1400
+ "interval_ms": {
1401
+ "type": "integer",
1402
+ "description": "Target sampling interval in ms. Actual interval = max(interval_ms, io_time); jitter recorded in summary.",
1403
+ "default": 100,
1404
+ },
1405
+ "duration_s": {
1406
+ "type": "number",
1407
+ "description": "Total capture duration in seconds (capped at 600). Either duration_s or max_frames must be > 0.",
1408
+ "default": 10,
1409
+ },
1410
+ "max_frames": {
1411
+ "type": "integer",
1412
+ "description": "Stop after N frames (alternative to duration_s).",
1413
+ "default": 0,
1414
+ },
1415
+ "out_path": {
1416
+ "type": "string",
1417
+ "description": "CSV output path. Default: <cwd>/lab/streams/ndtdbg_stream_<ts>.csv",
1418
+ },
1419
+ "decode": {
1420
+ "type": "string",
1421
+ "enum": ["hex", "int16le", "uint16le", "int8", "uint8"],
1422
+ "description": "Optional decoded columns besides raw hex (int16le for PT488 0xEE channels).",
1423
+ "default": "hex",
1424
+ },
1425
+ "retry": {
1426
+ "type": "integer",
1427
+ "description": "Retries per frame per register on all-0xFF / IO error before marking the frame FAILED.",
1428
+ "default": 2,
1429
+ },
1430
+ "fail_threshold": {
1431
+ "type": "integer",
1432
+ "description": "Abort capture after N consecutive IO_FAIL frames (0 = never abort); already-captured data is kept and returned.",
1433
+ "default": 50,
1434
+ },
1435
+ "require_target": {
1436
+ "type": "boolean",
1437
+ "description": "Probe reg 0x10 before capture; abort with TARGET_MISMATCH when the chip does not answer.",
1438
+ "default": False,
1439
+ },
1440
+ "dll_dir": {"type": "string", "description": "NdtDebugTool lib dir; default from lab.yaml ndtdbg.dll_dir"},
1441
+ "slave_addr": {"type": "string", "description": "IIC slave address, e.g. 0xA0; default from lab.yaml"},
1442
+ },
1443
+ "required": ["regs"],
1444
+ "additionalProperties": False,
1445
+ },
1446
+ "handler": handle_fw_ndtdbg_stream,
1447
+ },
1355
1448
  "fw_ndtdbg_capture_seq": {
1356
1449
  "description": "One-call protocol evidence: concurrently runs a NdtBox register write sequence and a sigrok I2C capture, returning the capture path for fw_logic_decode. NOTE: the default sequence (0xBE=01, 0x01=BE 42, poll 0x01, readback 0xBE) is an EXAMPLE only - always pass your target firmware's real register protocol via writes/poll_reg/poll_expect. Writes are double-gated (confirm: true + lab.yaml ndtdbg.write_allowed); degrades to sequence-only execution (capture_skipped) when sigrok-cli is missing.",
1357
1450
  "inputSchema": {
@@ -1526,7 +1619,7 @@ def process_request(req: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1526
1619
  },
1527
1620
  "serverInfo": {
1528
1621
  "name": "firmwareloop",
1529
- "version": "0.1.9"
1622
+ "version": "0.2.1"
1530
1623
  }
1531
1624
  }
1532
1625
  }
@@ -1615,10 +1708,53 @@ def process_request(req: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1615
1708
  return None
1616
1709
 
1617
1710
 
1711
+ def _local_bin_entrypoints() -> list:
1712
+ """uv tool 入口点 exe (~/.local/bin)——本命令自身与常驻 MCP server 都从这里运行。"""
1713
+ home = os.path.expanduser("~")
1714
+ return [os.path.join(home, ".local", "bin", name)
1715
+ for name in ("fwloop.exe", "fw-mcp.exe", "agentic-hil.exe")]
1716
+
1717
+
1718
+ def _stash_local_bin_entrypoints() -> list:
1719
+ """升级前把入口点 exe 重命名让位 (Windows 允许 rename 运行中的 exe, 不允许覆盖写)。
1720
+
1721
+ 否则 `fwloop update` 第一次必然失败: uv 复制新 fwloop.exe 的目标文件正是当前
1722
+ 命令自己的启动映像 (被锁定, os error 32), 第二次则因 "Nothing to upgrade"
1723
+ 跳过入口点同步。返回 [(原路径, 备份路径)]。
1724
+ """
1725
+ stashed = []
1726
+ for path in _local_bin_entrypoints():
1727
+ if not os.path.isfile(path):
1728
+ continue
1729
+ stash = path + ".upgrading"
1730
+ try:
1731
+ if os.path.exists(stash): # 上次升级残留 (旧映像仍被运行中进程持有)
1732
+ os.remove(stash)
1733
+ os.rename(path, stash)
1734
+ stashed.append((path, stash))
1735
+ print(f"[*] Entrypoint stashed for upgrade: {os.path.basename(path)} -> .upgrading")
1736
+ except OSError:
1737
+ pass # 让位失败不阻塞升级: uv 直接覆盖, 若 os error 32 走针对性诊断
1738
+ return stashed
1739
+
1740
+
1741
+ def _settle_stashed_entrypoints(stashed) -> None:
1742
+ """升级收尾: uv 已写入新入口点 -> 删备份 (旧映像仍被运行中进程持有时留待下次);
1743
+ uv 未写入 (升级失败/中止) -> 还原旧入口点, 保证 fwloop 命令不消失。"""
1744
+ for path, stash in stashed:
1745
+ try:
1746
+ if os.path.isfile(path):
1747
+ os.remove(stash) # 新 exe 已就位, 备份可弃
1748
+ else:
1749
+ os.rename(stash, path) # 升级未完成: 还原
1750
+ except OSError:
1751
+ pass # 旧映像仍被本命令/常驻 MCP 持有, 留待下次清理
1752
+
1753
+
1618
1754
  def handle_cli_update() -> int:
1619
1755
  """Handle `firmwareloop update` command."""
1620
1756
  print("============================================================")
1621
- print(" FirmwareLoop Auto-Updater (v0.1.9)")
1757
+ print(" FirmwareLoop Auto-Updater (v0.2.1)")
1622
1758
  print("============================================================")
1623
1759
 
1624
1760
  is_git_repo = os.path.exists(os.path.join(REPO_ROOT, ".git"))
@@ -1661,19 +1797,24 @@ def handle_cli_update() -> int:
1661
1797
  uv_path = shutil.which("uv")
1662
1798
  if uv_path:
1663
1799
  print("[*] Upgrading firmwareloop via `uv tool upgrade firmwareloop`...")
1800
+ # 关键: 入口点 exe (.local\bin\fwloop.exe) 正被本命令自身运行——Windows 禁止
1801
+ # 覆盖运行中的 exe (os error 32), 不让位则第一次 upgrade 必然失败。
1802
+ # rename 让位 (运行中的 exe 允许重命名), uv 复制新入口点后在收尾阶段清理/还原。
1803
+ stashed = _stash_local_bin_entrypoints()
1664
1804
  try:
1665
1805
  res = subprocess.run([uv_path, "tool", "upgrade", "firmwareloop"])
1666
- if res.returncode == 0:
1667
- print("\n============================================================")
1668
- print("[+] FirmwareLoop has been upgraded to the latest version!")
1669
- print("============================================================")
1670
- return 0
1671
- else:
1672
- print(f"[-] uv tool upgrade returned code {res.returncode}")
1673
- return res.returncode
1674
- except Exception as e:
1675
- print(f"[-] Error upgrading via uv tool: {e}")
1676
- return 1
1806
+ finally:
1807
+ _settle_stashed_entrypoints(stashed)
1808
+ if res.returncode == 0:
1809
+ print("\n============================================================")
1810
+ print("[+] FirmwareLoop has been upgraded to the latest version!")
1811
+ print("============================================================")
1812
+ return 0
1813
+ print(f"[-] uv tool upgrade returned code {res.returncode}")
1814
+ print(" 工具环境可能已升级成功, 仅入口点 exe 未更新 (os error 32 = 文件被占用)。")
1815
+ print(" 占用者通常是本命令自身或常驻的 MCP server 进程; 关闭 IDE 后重跑一次")
1816
+ print(" `fwloop update` 即可完成收尾。")
1817
+ return res.returncode
1677
1818
  else:
1678
1819
  print("[*] Upgrading via pip (`pip install --upgrade firmwareloop`)...")
1679
1820
  res = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "firmwareloop"])
@@ -1888,7 +2029,7 @@ def set_ndtdbg_dlldir(dll_path: str) -> str:
1888
2029
  def handle_cli_setup() -> int:
1889
2030
  """Handle `firmwareloop setup` command: simultaneously configure Antigravity CLI, Claude Code CLI, Qoder IDE, and CodeBuddy IDE."""
1890
2031
  print("============================================================")
1891
- print(" FirmwareLoop Multi-Agent Global Setup Helper (v0.1.9)")
2032
+ print(" FirmwareLoop Multi-Agent Global Setup Helper (v0.2.1)")
1892
2033
  print("============================================================")
1893
2034
  print("")
1894
2035
 
@@ -2013,7 +2154,7 @@ def handle_cli_setup() -> int:
2013
2154
  def handle_cli_init() -> int:
2014
2155
  """Handle `firmwareloop init` command."""
2015
2156
  print("============================================================")
2016
- print(" FirmwareLoop Multi-Agent Project Initializer (v0.1.9)")
2157
+ print(" FirmwareLoop Multi-Agent Project Initializer (v0.2.1)")
2017
2158
  print("============================================================")
2018
2159
  cwd = os.getcwd()
2019
2160
  print(f"[*] Initializing multi-agent guidelines & bench config in:\n {cwd}\n")
@@ -2034,7 +2175,7 @@ def handle_cli_init() -> int:
2034
2175
 
2035
2176
 
2036
2177
  def print_cli_help() -> None:
2037
- print("""FirmwareLoop (fwloop) — AI Agent Firmware Engineering & Lab Automation Platform (v0.1.9)
2178
+ print("""FirmwareLoop (fwloop) — AI Agent Firmware Engineering & Lab Automation Platform (v0.2.1)
2038
2179
 
2039
2180
  Usage:
2040
2181
  fwloop [command] (or: firmwareloop [command])
@@ -2074,7 +2215,7 @@ def main() -> None:
2074
2215
  sys.exit(2)
2075
2216
  sys.exit(handle_cli_ndtdebugtool(args[1]))
2076
2217
  elif cmd in ["version", "-v", "--version"]:
2077
- print("FirmwareLoop v0.1.9")
2218
+ print("FirmwareLoop v0.2.1")
2078
2219
  sys.exit(0)
2079
2220
  elif cmd in ["help", "-h", "--help"]:
2080
2221
  print_cli_help()
@@ -79,6 +79,72 @@ def parse_module_connect(data: bytes) -> int:
79
79
  return int.from_bytes(data[:2], "little")
80
80
 
81
81
 
82
+ # ---------------------------------------------------------------------------
83
+ # stream 连续采集 (纯函数部分, 平台无关可单测)
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def parse_reg_spec(spec: str) -> dict:
87
+ """解析寄存器 spec 'addr:len[:name]' -> {'addr', 'len', 'name'}。
88
+
89
+ addr 支持 0x 前缀; name 省略时自动生成 reg_<hex>。Fail-Closed: 格式/越界抛 CONFIG_ERROR。
90
+ """
91
+ parts = str(spec).split(":")
92
+ if len(parts) < 2 or len(parts) > 3 or not parts[0] or not parts[1]:
93
+ raise NdtBoxError("CONFIG_ERROR", "reg spec 格式非法 (addr:len[:name])", str(spec))
94
+ try:
95
+ addr = int(parts[0], 0)
96
+ length = int(parts[1], 0)
97
+ except ValueError as exc:
98
+ raise NdtBoxError("CONFIG_ERROR", "reg spec addr/len 不是合法整数", str(spec)) from exc
99
+ if not 0 <= addr <= 0xFF:
100
+ raise NdtBoxError("CONFIG_ERROR", "reg spec addr 越界 (0..0xFF)", hex(addr))
101
+ if not 1 <= length <= 256:
102
+ raise NdtBoxError("CONFIG_ERROR", "reg spec len 越界 (1..256)", str(length))
103
+ name = parts[2].strip() if len(parts) == 3 else ""
104
+ if name and not all(c.isalnum() or c == "_" for c in name):
105
+ raise NdtBoxError("CONFIG_ERROR", "reg spec name 只允许字母/数字/下划线", name)
106
+ return {"addr": addr, "len": length, "name": name or f"reg_{addr:02X}"}
107
+
108
+
109
+ def decode_payload(hex_str: str | None, mode: str) -> str | None:
110
+ """hex 载荷按模式解码为 '|' 分隔数值串; mode=hex 或空载荷返回 None。
111
+
112
+ 'BD 00 AB 00' + int16le -> '189|171' (小端); int16le 对 >=0x8000 做 C# (short) 有符号语义。
113
+ """
114
+ if mode == "hex" or not hex_str or hex_str == "-":
115
+ return None
116
+ try:
117
+ raw = bytes.fromhex(hex_str.replace(" ", ""))
118
+ except ValueError as exc:
119
+ raise NdtBoxError("CONFIG_ERROR", "hex 载荷非法", hex_str) from exc
120
+ vals: list[int] = []
121
+ if mode in ("int16le", "uint16le"):
122
+ for i in range(0, len(raw) - len(raw) % 2, 2):
123
+ v = int.from_bytes(raw[i:i + 2], "little")
124
+ if mode == "int16le" and v >= 0x8000:
125
+ v -= 0x10000
126
+ vals.append(v)
127
+ elif mode in ("int8", "uint8"):
128
+ for b in raw:
129
+ vals.append(b - 256 if (mode == "int8" and b >= 0x80) else b)
130
+ else:
131
+ raise NdtBoxError("CONFIG_ERROR", "decode 模式非法 (hex/int16le/uint16le/int8/uint8)", mode)
132
+ return "|".join(str(v) for v in vals) if vals else None
133
+
134
+
135
+ def interval_stats(frame_times_ms: list[float]) -> dict:
136
+ """帧完成时间序列 -> 实际间隔统计 {avg_ms, p95_ms, gaps_n}; 帧数 < 2 时返回空 dict。"""
137
+ gaps = [round(b - a, 1) for a, b in zip(frame_times_ms, frame_times_ms[1:])]
138
+ if not gaps:
139
+ return {}
140
+ ordered = sorted(gaps)
141
+ return {
142
+ "gaps_n": len(gaps),
143
+ "interval_actual_avg_ms": round(sum(gaps) / len(gaps), 1),
144
+ "interval_actual_p95_ms": ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))],
145
+ }
146
+
147
+
82
148
  def strip_nfw(file_bytes: bytes, header_len: int = 256) -> bytes:
83
149
  """.nfw 固件: 去 256 字节文件头 (ChipSea.cs LoadFile)。"""
84
150
  if len(file_bytes) <= header_len:
@@ -263,6 +329,62 @@ class NdtBoxIo:
263
329
  raise NdtBoxError("CONFIG_ERROR", "run_ops 空序列", None)
264
330
  return self._bridge_pipe("run", json.dumps(ops, separators=(",", ":")), timeout_ms)
265
331
 
332
+ def stream_run(self, spec: dict, on_row=None) -> dict:
333
+ """stream action: 桥进程内长驻 USB 会话连续采集 (读循环在 32 位桥内, 消除逐帧冷启动)。
334
+
335
+ spec: {"regs":[{"addr","len","name"}...], "interval_ms","duration_s","max_frames",
336
+ "retry","fail_threshold","require_target"}
337
+ on_row(parts): 每寄存器读完成回调, parts = ['#F', frame, t_rel_ms, reg, hex|'-', tag]
338
+ (tag: OK / FF_GAP / IO_FAIL), tab 分隔。
339
+ 末行 JSON 为桥 summary (frames_* / aborted / abort_reason / duration_actual_ms);
340
+ 桥失败抛 NdtBoxError; Ctrl+C 传播 KeyboardInterrupt, finally 杀桥进程
341
+ (已回调数据由调用方持有, CSV 保留)。
342
+ """
343
+ if not os.path.exists(PS32):
344
+ raise NdtBoxError("CONFIG_ERROR", f"32 位 PowerShell 不存在: {PS32}")
345
+ if not os.path.exists(self.bridge_ps1):
346
+ raise NdtBoxError("CONFIG_ERROR", f"桥脚本不存在: {self.bridge_ps1}")
347
+ args = [PS32, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
348
+ "-File", self.bridge_ps1, "-Action", "stream",
349
+ "-DllDir", self.dll_dir, "-SlaveAddr", str(self.slave_addr),
350
+ "-Speed", str(self.speed)]
351
+ proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
352
+ stderr=subprocess.PIPE, text=True, encoding="utf-8",
353
+ errors="replace")
354
+ try:
355
+ proc.stdin.write(json.dumps(spec, separators=(",", ":")))
356
+ proc.stdin.close()
357
+ except Exception:
358
+ pass
359
+ summary: dict | None = None
360
+ try:
361
+ for line in proc.stdout:
362
+ line = line.rstrip("\r\n")
363
+ if not line:
364
+ continue
365
+ if line.startswith("#F\t"):
366
+ if on_row:
367
+ on_row(line.split("\t"))
368
+ elif line.startswith("{"):
369
+ summary = json.loads(line)
370
+ # 其他行 (未来 #progress 等) 忽略
371
+ finally:
372
+ try:
373
+ if proc.poll() is None:
374
+ proc.kill()
375
+ stderr_tail = (proc.stderr.read() or "").strip()[-400:]
376
+ proc.wait(timeout=10)
377
+ except Exception:
378
+ stderr_tail = ""
379
+ if summary is None:
380
+ raise NdtBoxError("UNKNOWN_ERROR", "桥 stream 无 summary 输出 (进程提前退出/挂死被终止)",
381
+ stderr_tail or None)
382
+ if not summary.get("ok"):
383
+ raise NdtBoxError(summary.get("error_class", "UNKNOWN_ERROR"),
384
+ summary.get("error", "桥 stream 返回失败"),
385
+ summary.get("detail"))
386
+ return summary.get("data", {})
387
+
266
388
  def flash_exec(self, plan: dict, timeout_ms: int | None = None) -> dict:
267
389
  """单进程完整固件烧录 (bridge "flash"), 复刻原厂 GUI 进程模型:
268
390
  usb2io(IO10) 断电复位进 boot + USBIOX IIC 全序列在**同一进程同一会话**内连续执行,
@@ -4,7 +4,7 @@
4
4
  # 输出: stdout 单行 JSON {ok, action, data} 或 {ok:false, error_class, error, detail}。
5
5
  param(
6
6
  [Parameter(Mandatory = $true)]
7
- [ValidateSet("open", "read", "write", "version", "connect", "poll", "close", "raw", "vbrt", "run", "flash")]
7
+ [ValidateSet("open", "read", "write", "version", "connect", "poll", "close", "raw", "vbrt", "run", "flash", "stream")]
8
8
  [string]$Action,
9
9
 
10
10
  [string]$DllDir,
@@ -440,6 +440,126 @@ try {
440
440
  Write-Output (@{ ok = $true; action = "flash"; data = @{ verify = "pass"; pages_written = $wTotal; read_windows = $rTotal } } | ConvertTo-Json -Compress -Depth 4)
441
441
  exit 0
442
442
  }
443
+ "stream" {
444
+ # 高速连续采集 (只读): 单桥进程长驻 USB 会话, 循环内只做 I2C 事务 + sleep——
445
+ # 消除逐帧 spawn 的冷启动开销 (进程+DLL+USB 会话 ≈2s), 单帧开销 = 一次 StreamI2C 事务。
446
+ # stdin JSON: {"regs":[{"addr":238,"len":8,"name":"ch"},...],"interval_ms":100,
447
+ # "duration_s":10,"max_frames":0,"retry":2,"fail_threshold":50,
448
+ # "require_target":false}
449
+ # 输出行协议 (tab 分隔, 全 ASCII, Python 端逐行消费写 CSV):
450
+ # #F\t<frame>\t<t_rel_ms>\t<reg>\t<hex|- >\t<tag> 每寄存器一行 (tag: OK/FF_GAP/IO_FAIL)
451
+ # 末行 JSON: {ok, action:"stream", data:{frames_ok, frames_ff_gap, frames_io_fail,
452
+ # frames_total, aborted, abort_reason, duration_actual_ms}}
453
+ # 帧语义 (提案 §4.2): OK=读成功且非全FF; FF_GAP=读成功但全FF (总线高阻特征, 只统计不熔断);
454
+ # IO_FAIL=重试后仍 StreamI2C=false, 连续计数, 达 fail_threshold 熔断 (优雅收尾保留已采数据)。
455
+ $cfgJson = [Console]::In.ReadToEnd()
456
+ $cfg = $cfgJson | ConvertFrom-Json
457
+ if (-not $cfg) { Fail "CONFIG_ERROR" "stream 需要 stdin 传入配置 JSON" $null }
458
+ $sRegs = @($cfg.regs)
459
+ if ($sRegs.Count -eq 0) { Fail "CONFIG_ERROR" "stream regs 为空" $null }
460
+ $sInterval = if ($null -ne $cfg.interval_ms) { [int]$cfg.interval_ms } else { 100 }
461
+ $sDurationS = [double]$cfg.duration_s
462
+ $sMaxFrames = if ($null -ne $cfg.max_frames) { [int]$cfg.max_frames } else { 0 }
463
+ $sRetry = if ($null -ne $cfg.retry) { [int]$cfg.retry } else { 2 }
464
+ $sFailThr = if ($null -ne $cfg.fail_threshold) { [int]$cfg.fail_threshold } else { 50 }
465
+ $sRequireTarget = [bool]$cfg.require_target
466
+ if ($sDurationS -le 0 -and $sMaxFrames -le 0) { Fail "CONFIG_ERROR" "stream 需要 duration_s 或 max_frames 之一 > 0" $null }
467
+ if ($sInterval -lt 1) { Fail "CONFIG_ERROR" "stream interval_ms 必须 >= 1" $sInterval }
468
+ foreach ($r in $sRegs) {
469
+ if (([int]$r.len) -lt 1 -or ([int]$r.len) -gt 256) { Fail "CONFIG_ERROR" "stream 读长度越界 (1..256)" $r.len }
470
+ }
471
+
472
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
473
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
474
+ $h = Open-Device 0 # 打不开 -> Fail PROBE_NOT_FOUND (发生在任何 #F 行之前)
475
+
476
+ if ($sRequireTarget) {
477
+ $tb = New-Object byte[] 2
478
+ $tb[0] = [byte]$SlaveAddr; $tb[1] = 0x10
479
+ $vb = New-Object byte[] 4
480
+ $verOk = $false
481
+ for ($vt = 0; $vt -le $sRetry; $vt++) {
482
+ if ((Invoke-WriteRead $h $tb $vb)) {
483
+ $meaningful = $false
484
+ foreach ($b in $vb) { if ($b -ne 0 -and $b -ne 0xFF) { $meaningful = $true; break } }
485
+ if ($meaningful) { $verOk = $true; break }
486
+ }
487
+ Start-Sleep -Milliseconds 100
488
+ }
489
+ if (-not $verOk) {
490
+ [STDataCollection.Icb]::USBIO_CloseDevice([uint32]0) | Out-Null
491
+ Fail "TARGET_MISMATCH" "stream 起始探测失败 (0x10 无有效应答); 检查接线/上拉/供电/地址, 或去掉 require_target" $null
492
+ }
493
+ }
494
+
495
+ $framesOk = 0; $framesGap = 0; $framesIoFail = 0
496
+ $frameNo = 0
497
+ $consecIoFail = 0
498
+ $aborted = $false; $abortReason = ""
499
+ $durationMsTotal = [int]($sDurationS * 1000)
500
+ while ($true) {
501
+ $frameNo++
502
+ $frameStartMs = $sw.Elapsed.TotalMilliseconds
503
+ $hasIoFail = $false; $hasGap = $false
504
+ foreach ($r in $sRegs) {
505
+ $reg = [int]$r.addr; $rlen = [int]$r.len
506
+ $hexOut = "-"; $tag = "OK"
507
+ try {
508
+ for ($attempt = 0; $attempt -le $sRetry; $attempt++) {
509
+ $tx = New-Object byte[] 2
510
+ $tx[0] = [byte]$SlaveAddr; $tx[1] = [byte]$reg
511
+ $rb = New-Object byte[] $rlen
512
+ if (Invoke-WriteRead $h $tx $rb) {
513
+ # ForEach-Object 将管道元素绑定到 $_——scriptblock 用 param()
514
+ # 形式收参恒为 null (Null 方法调用), 必须内联 $_
515
+ $hexStr = (($rb | ForEach-Object { $_.ToString("X2") }) -join " ")
516
+ $allFF = $true
517
+ foreach ($b in $rb) { if ($b -ne 0xFF) { $allFF = $false; break } }
518
+ if ($allFF) {
519
+ $tag = "FF_GAP"; $hexOut = $hexStr # 总线高阻特征: 记录原值, 重试
520
+ if ($attempt -lt $sRetry) { Start-Sleep -Milliseconds 5 }
521
+ } else {
522
+ $tag = "OK"; $hexOut = $hexStr
523
+ break
524
+ }
525
+ } else {
526
+ $tag = "IO_FAIL"; $hexOut = "-"
527
+ if ($attempt -lt $sRetry) { Start-Sleep -Milliseconds 5 }
528
+ }
529
+ }
530
+ } catch {
531
+ # 单寄存器异常降级为 IO_FAIL 行继续采集, 不作废整个 stream (fail-closed 韧性)
532
+ $tag = "IO_FAIL"; $hexOut = "-"
533
+ }
534
+ if ($tag -eq "IO_FAIL") { $hasIoFail = $true }
535
+ elseif ($tag -eq "FF_GAP") { $hasGap = $true }
536
+ $tRel = [math]::Round($sw.Elapsed.TotalMilliseconds, 1)
537
+ [Console]::WriteLine("#F`t{0}`t{1}`t0x{2:X2}`t{3}`t{4}" -f $frameNo, $tRel, $reg, $hexOut, $tag)
538
+ [Console]::Out.Flush()
539
+ }
540
+ if ($hasIoFail) { $framesIoFail++; $consecIoFail++ }
541
+ else {
542
+ if ($hasGap) { $framesGap++ } else { $framesOk++ }
543
+ $consecIoFail = 0 # 读成功帧 (OK/FF_GAP) 重置连续失败计数
544
+ }
545
+ if ($sFailThr -gt 0 -and $consecIoFail -ge $sFailThr) { $aborted = $true; $abortReason = "fail_threshold"; break }
546
+ if ($sMaxFrames -gt 0 -and $frameNo -ge $sMaxFrames) { break }
547
+ $elapsedMs = $sw.Elapsed.TotalMilliseconds
548
+ if ($sDurationS -gt 0 -and $elapsedMs -ge $durationMsTotal) { break }
549
+ if ($sDurationS -gt 0) {
550
+ $wait = $sInterval - ($elapsedMs - $frameStartMs)
551
+ if ($wait -gt 0) { Start-Sleep -Milliseconds ([int]$wait) }
552
+ }
553
+ }
554
+ [STDataCollection.Icb]::USBIO_CloseDevice([uint32]0) | Out-Null
555
+ $sum = @{ ok = $true; action = "stream"; data = @{
556
+ frames_ok = $framesOk; frames_ff_gap = $framesGap; frames_io_fail = $framesIoFail;
557
+ frames_total = $frameNo; aborted = $aborted; abort_reason = $abortReason;
558
+ duration_actual_ms = [int]$sw.Elapsed.TotalMilliseconds } }
559
+ [Console]::WriteLine(($sum | ConvertTo-Json -Compress -Depth 5))
560
+ [Console]::Out.Flush()
561
+ exit 0
562
+ }
443
563
  }
444
564
  } catch {
445
565
  Fail "UNKNOWN_ERROR" "bridge 异常: $($_.Exception.Message)" $_.ScriptStackTrace
@@ -9,9 +9,11 @@ import argparse
9
9
  import json
10
10
  import os
11
11
  import sys
12
+ from datetime import datetime
12
13
 
13
14
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
- from lib.ndtdbg import ChipSeaFxx, NdtBoxError, NdtBoxIo # noqa: E402
15
+ from lib.ndtdbg import (ChipSeaFxx, NdtBoxError, NdtBoxIo, decode_payload, # noqa: E402
16
+ interval_stats, parse_reg_spec)
15
17
 
16
18
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
17
19
 
@@ -195,6 +197,112 @@ def cmd_poll(io: NdtBoxIo, duration_ms: int, interval_ms: int, max_frames: int,
195
197
  ok("poll", result, log=f"poll({duration_ms}ms, {interval_ms}ms): {len(frames)} frames")
196
198
 
197
199
 
200
+ def cmd_stream(io: NdtBoxIo, config: dict, specs: list, interval_ms, duration_s: float,
201
+ max_frames: int, out_path, decode: str, retry: int, fail_threshold,
202
+ require_target: bool) -> None:
203
+ """高速连续采集 (只读): 桥进程内长驻 USB 会话, 单帧开销 = 一次 I2C 事务。
204
+
205
+ 帧语义: OK / FF_GAP (读成功但全 FF, 总线高阻特征, 只统计不熔断) /
206
+ IO_FAIL (重试后仍无应答, 连续计数达 fail_threshold 熔断)。Ctrl+C 优雅收尾,
207
+ CSV 已采数据保留, summary 标记 aborted。
208
+ """
209
+ # 默认值: lab.yaml ndtdbg.stream 段可覆盖内置默认 (均有默认值, 非必填)
210
+ stream_cfg = (config.get("ndtdbg") or {}).get("stream") or {}
211
+ if interval_ms is None:
212
+ interval_ms = int(stream_cfg.get("default_interval_ms", 100))
213
+ if fail_threshold is None:
214
+ fail_threshold = int(stream_cfg.get("fail_threshold", 50))
215
+ if duration_s <= 0 and max_frames <= 0:
216
+ fail("CONFIG_ERROR", "stream 需要 --duration-s 或 --max-frames 之一 > 0",
217
+ detail=f"duration_s={duration_s}, max_frames={max_frames}")
218
+
219
+ if not out_path:
220
+ out_path = os.path.join("lab", "streams",
221
+ datetime.now().strftime("ndtdbg_stream_%Y%m%d_%H%M%S.csv"))
222
+ abs_out = os.path.abspath(out_path)
223
+ windir = os.environ.get("WINDIR", r"C:\Windows")
224
+ if abs_out.lower().startswith(windir.lower() + os.sep):
225
+ fail("CONFIG_ERROR", "输出路径拒绝写入系统目录", abs_out)
226
+ out_dir = os.path.dirname(abs_out)
227
+ if out_dir:
228
+ os.makedirs(out_dir, exist_ok=True)
229
+
230
+ spec = {
231
+ "regs": [{"addr": s["addr"], "len": s["len"], "name": s["name"]} for s in specs],
232
+ "interval_ms": interval_ms, "duration_s": float(duration_s), "max_frames": max_frames,
233
+ "retry": retry, "fail_threshold": fail_threshold, "require_target": require_target,
234
+ }
235
+
236
+ rows = {"ok": 0, "FF_GAP": 0, "IO_FAIL": 0} # 行级计数 (Ctrl+C 时桥内帧级统计丢失, 以此近似)
237
+ frame_times: list = [] # 帧完成 t_rel_ms 序列 (间隔统计)
238
+ state = {"frame": None, "t": None}
239
+
240
+ fh = open(abs_out, "w", encoding="utf-8", newline="")
241
+ try:
242
+ header = "frame,timestamp_iso,t_rel_ms,reg,raw_hex"
243
+ if decode != "hex":
244
+ header += "," + ",".join(f"{s['name']}_decoded" for s in specs)
245
+ fh.write(header + "\n")
246
+
247
+ def on_row(parts: list) -> None:
248
+ # 桥行协议: '#F', frame, t_rel_ms, reg, hex|'-', tag (hex 已空格分隔, 直接透传)
249
+ frame = int(parts[1]); t_rel = float(parts[2])
250
+ reg = parts[3]; hexs = parts[4]; tag = parts[5] if len(parts) > 5 else "OK"
251
+ line = (f"{frame},{datetime.now().isoformat(timespec='milliseconds')},"
252
+ f"{t_rel:g},{reg},{hexs}")
253
+ if decode != "hex":
254
+ line += "," + (decode_payload(hexs, decode) or "")
255
+ if tag != "OK":
256
+ line += f";{tag}"
257
+ fh.write(line + "\n")
258
+ if frame != state["frame"]:
259
+ if state["t"] is not None:
260
+ frame_times.append(state["t"])
261
+ state["frame"] = frame
262
+ state["t"] = t_rel
263
+ rows[tag] = rows.get(tag, 0) + 1
264
+
265
+ try:
266
+ data = io.stream_run(spec, on_row=on_row)
267
+ except KeyboardInterrupt:
268
+ data = None # 桥被终止; 行级计数近似, CSV 已写数据保留
269
+ finally:
270
+ fh.close()
271
+
272
+ if state["t"] is not None:
273
+ frame_times.append(state["t"])
274
+ total = data.get("frames_total") if data else sum(rows.values())
275
+ summary = {
276
+ "csv_path": abs_out,
277
+ "frames_ok": data.get("frames_ok", rows["ok"]) if data else rows["ok"],
278
+ "frames_ff_gap": data.get("frames_ff_gap", rows["FF_GAP"]) if data else rows["FF_GAP"],
279
+ "frames_io_fail": data.get("frames_io_fail", rows["IO_FAIL"]) if data else rows["IO_FAIL"],
280
+ "frames_total": total if total else sum(rows.values()),
281
+ "interval_target_ms": interval_ms,
282
+ "duration_actual_s": round((data.get("duration_actual_ms") or 0) / 1000.0, 2) if data
283
+ else round((state["t"] or 0) / 1000.0, 2),
284
+ "aborted": bool(data.get("aborted")) if data else True,
285
+ "abort_reason": data.get("abort_reason") or ("interrupted" if data is None else ""),
286
+ }
287
+ summary.update(interval_stats(frame_times))
288
+ if decode != "hex":
289
+ summary["decode"] = decode
290
+
291
+ if summary["aborted"] and summary["abort_reason"] == "fail_threshold":
292
+ # 熔断: Fail-Closed 错误信封, 但 data 携带已采数据 summary
293
+ print(json.dumps({
294
+ "schema": "ndtdbg-result/v1", "ok": False, "error_class": "STREAM_ABORTED",
295
+ "error": f"连续 IO_FAIL 达到熔断阈值 {fail_threshold}, 已采数据保留于 CSV",
296
+ "data": summary,
297
+ }, ensure_ascii=False))
298
+ sys.exit(1)
299
+ payload = {"schema": "ndtdbg-result/v1", "ok": True, "action": "stream", "data": summary}
300
+ if data is None:
301
+ payload["log"] = "用户中断, 已采数据保留"
302
+ print(json.dumps(payload, ensure_ascii=False))
303
+ sys.exit(0)
304
+
305
+
198
306
  def cmd_flash_bin(io: NdtBoxIo, config: dict, file_path: str, erase_ms: int, confirm: bool) -> None:
199
307
  # 烧录 = 整片擦除 + 页写, 破坏性高于 write-reg, 同款双闸门
200
308
  allowed = (config.get("ndtdbg") or {}).get("write_allowed", False)
@@ -249,6 +357,26 @@ def build_parser() -> argparse.ArgumentParser:
249
357
  f.add_argument("--file", required=True,
250
358
  help="固件文件 (.bin 自动 128 对齐补 0xFF; .nfw 去 256 头)")
251
359
  f.add_argument("--erase-ms", type=int, default=2000, help="擦除等待 ms (对齐原厂 2000)")
360
+
361
+ st = sub.add_parser("stream", parents=[common],
362
+ help="高速连续采集寄存器组到 CSV (单会话长驻, 只读)")
363
+ st.add_argument("--regs", nargs="+", default=["0xEE:8"],
364
+ help="寄存器 spec addr:len[:name], 如 0xEE:8:ch 0x20:138:event")
365
+ st.add_argument("--interval-ms", type=int, default=None,
366
+ help="目标采样间隔 ms (默认 lab.yaml ndtdbg.stream.default_interval_ms 或 100)")
367
+ st.add_argument("--duration-s", type=float, default=0,
368
+ help="采集总时长秒 (与 --max-frames 至少一个 > 0)")
369
+ st.add_argument("--max-frames", type=int, default=0,
370
+ help="达到 N 帧后停止 (与 --duration-s 二选一)")
371
+ st.add_argument("--out", help="CSV 输出路径 (默认 lab/streams/ndtdbg_stream_<ts>.csv)")
372
+ st.add_argument("--decode", choices=["hex", "int16le", "uint16le", "int8", "uint8"],
373
+ default="hex", help="decoded 附加列的解码模式 (默认仅 raw hex)")
374
+ st.add_argument("--retry", type=int, default=2,
375
+ help="每帧每寄存器重试次数 (全 FF / IO 错误, 默认 2)")
376
+ st.add_argument("--fail-threshold", type=int, default=None,
377
+ help="连续 IO_FAIL 熔断阈值 (默认 lab.yaml ndtdbg.stream.fail_threshold 或 50; 0=不熔断)")
378
+ st.add_argument("--require-target", action="store_true",
379
+ help="采集前探测 0x10, 无有效芯片应答则 TARGET_MISMATCH")
252
380
  return p
253
381
 
254
382
 
@@ -273,6 +401,11 @@ def main(argv=None) -> None:
273
401
  cmd_poll(io, args.duration_ms, args.interval_ms, args.max_frames, args.full)
274
402
  elif args.cmd == "flash-bin":
275
403
  cmd_flash_bin(io, config, args.file, args.erase_ms, args.confirm)
404
+ elif args.cmd == "stream":
405
+ specs = [parse_reg_spec(s) for s in args.regs]
406
+ cmd_stream(io, config, specs, args.interval_ms, args.duration_s,
407
+ args.max_frames, args.out, args.decode, args.retry,
408
+ args.fail_threshold, args.require_target)
276
409
  else: # pragma: no cover
277
410
  fail("CONFIG_ERROR", f"unknown cmd: {args.cmd}")
278
411
  except NdtBoxError as exc:
@@ -1,6 +1,6 @@
1
1
  <#
2
2
  .SYNOPSIS
3
- Dual-Tier MCP setup and registration helper (v0.1.9).
3
+ Dual-Tier MCP setup and registration helper (v0.2.1).
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.1.9)" -ForegroundColor Green
245
+ Write-Host " FirmwareLoop Dual-Tier MCP & Agent Setup Helper (v0.2.1)" -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