firmwareloop 0.1.9__tar.gz → 0.2.0__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.0}/PKG-INFO +3 -3
  2. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/README.md +2 -2
  3. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/firmwareloop.egg-info/PKG-INFO +3 -3
  4. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/pyproject.toml +1 -1
  5. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/__init__.py +1 -1
  6. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/fw_mcp_server.py +100 -7
  7. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/lib/ndtdbg.py +122 -0
  8. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/ndtdbg_bridge.ps1 +115 -1
  9. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/ndtdbg_cli.py +136 -1
  10. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/setup-agent-mcp.ps1 +2 -2
  11. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/LICENSE +0 -0
  12. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/firmwareloop.egg-info/SOURCES.txt +0 -0
  13. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/firmwareloop.egg-info/dependency_links.txt +0 -0
  14. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/firmwareloop.egg-info/entry_points.txt +0 -0
  15. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/firmwareloop.egg-info/requires.txt +0 -0
  16. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/firmwareloop.egg-info/top_level.txt +0 -0
  17. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/setup.cfg +0 -0
  18. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/acceptance-scenario.ps1 +0 -0
  19. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/build.ps1 +0 -0
  20. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/can.ps1 +0 -0
  21. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/check-qoder-mcp.ps1 +0 -0
  22. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/common/build-backends.psm1 +0 -0
  23. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/common/fw.psm1 +0 -0
  24. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/common/uart_probe.py +0 -0
  25. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/doctor.ps1 +0 -0
  26. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/flash.ps1 +0 -0
  27. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/instrument_cli.py +0 -0
  28. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/lib/__init__.py +0 -0
  29. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/lib/instruments.py +0 -0
  30. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/logic_capture.ps1 +0 -0
  31. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/logic_decode.ps1 +0 -0
  32. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/reset.ps1 +0 -0
  33. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/sigrok_csv_to_fwloop.py +0 -0
  34. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/tools/test-backends.ps1 +0 -0
  35. {firmwareloop-0.1.9 → firmwareloop-0.2.0}/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.0
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.0)
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.0)
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.0)
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.0)
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.0
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.0)
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.0)
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.0"
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.0"
@@ -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.0).
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.0"
1530
1623
  }
1531
1624
  }
1532
1625
  }
@@ -1618,7 +1711,7 @@ def process_request(req: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1618
1711
  def handle_cli_update() -> int:
1619
1712
  """Handle `firmwareloop update` command."""
1620
1713
  print("============================================================")
1621
- print(" FirmwareLoop Auto-Updater (v0.1.9)")
1714
+ print(" FirmwareLoop Auto-Updater (v0.2.0)")
1622
1715
  print("============================================================")
1623
1716
 
1624
1717
  is_git_repo = os.path.exists(os.path.join(REPO_ROOT, ".git"))
@@ -1888,7 +1981,7 @@ def set_ndtdbg_dlldir(dll_path: str) -> str:
1888
1981
  def handle_cli_setup() -> int:
1889
1982
  """Handle `firmwareloop setup` command: simultaneously configure Antigravity CLI, Claude Code CLI, Qoder IDE, and CodeBuddy IDE."""
1890
1983
  print("============================================================")
1891
- print(" FirmwareLoop Multi-Agent Global Setup Helper (v0.1.9)")
1984
+ print(" FirmwareLoop Multi-Agent Global Setup Helper (v0.2.0)")
1892
1985
  print("============================================================")
1893
1986
  print("")
1894
1987
 
@@ -2013,7 +2106,7 @@ def handle_cli_setup() -> int:
2013
2106
  def handle_cli_init() -> int:
2014
2107
  """Handle `firmwareloop init` command."""
2015
2108
  print("============================================================")
2016
- print(" FirmwareLoop Multi-Agent Project Initializer (v0.1.9)")
2109
+ print(" FirmwareLoop Multi-Agent Project Initializer (v0.2.0)")
2017
2110
  print("============================================================")
2018
2111
  cwd = os.getcwd()
2019
2112
  print(f"[*] Initializing multi-agent guidelines & bench config in:\n {cwd}\n")
@@ -2034,7 +2127,7 @@ def handle_cli_init() -> int:
2034
2127
 
2035
2128
 
2036
2129
  def print_cli_help() -> None:
2037
- print("""FirmwareLoop (fwloop) — AI Agent Firmware Engineering & Lab Automation Platform (v0.1.9)
2130
+ print("""FirmwareLoop (fwloop) — AI Agent Firmware Engineering & Lab Automation Platform (v0.2.0)
2038
2131
 
2039
2132
  Usage:
2040
2133
  fwloop [command] (or: firmwareloop [command])
@@ -2074,7 +2167,7 @@ def main() -> None:
2074
2167
  sys.exit(2)
2075
2168
  sys.exit(handle_cli_ndtdebugtool(args[1]))
2076
2169
  elif cmd in ["version", "-v", "--version"]:
2077
- print("FirmwareLoop v0.1.9")
2170
+ print("FirmwareLoop v0.2.0")
2078
2171
  sys.exit(0)
2079
2172
  elif cmd in ["help", "-h", "--help"]:
2080
2173
  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,120 @@ 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
+ $hexFmt = { param($b) $b.ToString("X2") }
501
+ while ($true) {
502
+ $frameNo++
503
+ $frameStartMs = $sw.Elapsed.TotalMilliseconds
504
+ $hasIoFail = $false; $hasGap = $false
505
+ foreach ($r in $sRegs) {
506
+ $reg = [int]$r.addr; $rlen = [int]$r.len
507
+ $hexOut = "-"; $tag = "OK"
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
+ $hexStr = (($rb | ForEach-Object $hexFmt) -join "")
514
+ $allFF = $true
515
+ foreach ($b in $rb) { if ($b -ne 0xFF) { $allFF = $false; break } }
516
+ if ($allFF) {
517
+ $tag = "FF_GAP"; $hexOut = $hexStr # 总线高阻特征: 记录原值, 重试
518
+ if ($attempt -lt $sRetry) { Start-Sleep -Milliseconds 5 }
519
+ } else {
520
+ $tag = "OK"; $hexOut = $hexStr
521
+ break
522
+ }
523
+ } else {
524
+ $tag = "IO_FAIL"; $hexOut = "-"
525
+ if ($attempt -lt $sRetry) { Start-Sleep -Milliseconds 5 }
526
+ }
527
+ }
528
+ if ($tag -eq "IO_FAIL") { $hasIoFail = $true }
529
+ elseif ($tag -eq "FF_GAP") { $hasGap = $true }
530
+ $tRel = [math]::Round($sw.Elapsed.TotalMilliseconds, 1)
531
+ [Console]::WriteLine("#F`t{0}`t{1}`t0x{2:X2}`t{3}`t{4}" -f $frameNo, $tRel, $reg, $hexOut, $tag)
532
+ [Console]::Out.Flush()
533
+ }
534
+ if ($hasIoFail) { $framesIoFail++; $consecIoFail++ }
535
+ else {
536
+ if ($hasGap) { $framesGap++ } else { $framesOk++ }
537
+ $consecIoFail = 0 # 读成功帧 (OK/FF_GAP) 重置连续失败计数
538
+ }
539
+ if ($sFailThr -gt 0 -and $consecIoFail -ge $sFailThr) { $aborted = $true; $abortReason = "fail_threshold"; break }
540
+ if ($sMaxFrames -gt 0 -and $frameNo -ge $sMaxFrames) { break }
541
+ $elapsedMs = $sw.Elapsed.TotalMilliseconds
542
+ if ($sDurationS -gt 0 -and $elapsedMs -ge $durationMsTotal) { break }
543
+ if ($sDurationS -gt 0) {
544
+ $wait = $sInterval - ($elapsedMs - $frameStartMs)
545
+ if ($wait -gt 0) { Start-Sleep -Milliseconds ([int]$wait) }
546
+ }
547
+ }
548
+ [STDataCollection.Icb]::USBIO_CloseDevice([uint32]0) | Out-Null
549
+ $sum = @{ ok = $true; action = "stream"; data = @{
550
+ frames_ok = $framesOk; frames_ff_gap = $framesGap; frames_io_fail = $framesIoFail;
551
+ frames_total = $frameNo; aborted = $aborted; abort_reason = $abortReason;
552
+ duration_actual_ms = [int]$sw.Elapsed.TotalMilliseconds } }
553
+ [Console]::WriteLine(($sum | ConvertTo-Json -Compress -Depth 5))
554
+ [Console]::Out.Flush()
555
+ exit 0
556
+ }
443
557
  }
444
558
  } catch {
445
559
  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,114 @@ 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
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
+ hex_fmt = (" ".join(hexs[i:i + 2] for i in range(0, len(hexs), 2))
252
+ if hexs and hexs != "-" else hexs)
253
+ line = (f"{frame},{datetime.now().isoformat(timespec='milliseconds')},"
254
+ f"{t_rel:g},{reg},{hex_fmt}")
255
+ if decode != "hex":
256
+ line += "," + (decode_payload(hexs, decode) or "")
257
+ if tag != "OK":
258
+ line += f";{tag}"
259
+ fh.write(line + "\n")
260
+ if frame != state["frame"]:
261
+ if state["t"] is not None:
262
+ frame_times.append(state["t"])
263
+ state["frame"] = frame
264
+ state["t"] = t_rel
265
+ rows[tag] = rows.get(tag, 0) + 1
266
+
267
+ try:
268
+ data = io.stream_run(spec, on_row=on_row)
269
+ except KeyboardInterrupt:
270
+ data = None # 桥被终止; 行级计数近似, CSV 已写数据保留
271
+ finally:
272
+ fh.close()
273
+
274
+ if state["t"] is not None:
275
+ frame_times.append(state["t"])
276
+ total = data.get("frames_total") if data else sum(rows.values())
277
+ summary = {
278
+ "csv_path": abs_out,
279
+ "frames_ok": data.get("frames_ok", rows["ok"]) if data else rows["ok"],
280
+ "frames_ff_gap": data.get("frames_ff_gap", rows["FF_GAP"]) if data else rows["FF_GAP"],
281
+ "frames_io_fail": data.get("frames_io_fail", rows["IO_FAIL"]) if data else rows["IO_FAIL"],
282
+ "frames_total": total if total else sum(rows.values()),
283
+ "interval_target_ms": interval_ms,
284
+ "duration_actual_s": round((data.get("duration_actual_ms") or 0) / 1000.0, 2) if data
285
+ else round((state["t"] or 0) / 1000.0, 2),
286
+ "aborted": bool(data.get("aborted")) if data else True,
287
+ "abort_reason": data.get("abort_reason") or ("interrupted" if data is None else ""),
288
+ }
289
+ summary.update(interval_stats(frame_times))
290
+ if decode != "hex":
291
+ summary["decode"] = decode
292
+
293
+ if summary["aborted"] and summary["abort_reason"] == "fail_threshold":
294
+ # 熔断: Fail-Closed 错误信封, 但 data 携带已采数据 summary
295
+ print(json.dumps({
296
+ "schema": "ndtdbg-result/v1", "ok": False, "error_class": "STREAM_ABORTED",
297
+ "error": f"连续 IO_FAIL 达到熔断阈值 {fail_threshold}, 已采数据保留于 CSV",
298
+ "data": summary,
299
+ }, ensure_ascii=False))
300
+ sys.exit(1)
301
+ payload = {"schema": "ndtdbg-result/v1", "ok": True, "action": "stream", "data": summary}
302
+ if data is None:
303
+ payload["log"] = "用户中断, 已采数据保留"
304
+ print(json.dumps(payload, ensure_ascii=False))
305
+ sys.exit(0)
306
+
307
+
198
308
  def cmd_flash_bin(io: NdtBoxIo, config: dict, file_path: str, erase_ms: int, confirm: bool) -> None:
199
309
  # 烧录 = 整片擦除 + 页写, 破坏性高于 write-reg, 同款双闸门
200
310
  allowed = (config.get("ndtdbg") or {}).get("write_allowed", False)
@@ -249,6 +359,26 @@ def build_parser() -> argparse.ArgumentParser:
249
359
  f.add_argument("--file", required=True,
250
360
  help="固件文件 (.bin 自动 128 对齐补 0xFF; .nfw 去 256 头)")
251
361
  f.add_argument("--erase-ms", type=int, default=2000, help="擦除等待 ms (对齐原厂 2000)")
362
+
363
+ st = sub.add_parser("stream", parents=[common],
364
+ help="高速连续采集寄存器组到 CSV (单会话长驻, 只读)")
365
+ st.add_argument("--regs", nargs="+", default=["0xEE:8"],
366
+ help="寄存器 spec addr:len[:name], 如 0xEE:8:ch 0x20:138:event")
367
+ st.add_argument("--interval-ms", type=int, default=None,
368
+ help="目标采样间隔 ms (默认 lab.yaml ndtdbg.stream.default_interval_ms 或 100)")
369
+ st.add_argument("--duration-s", type=float, default=0,
370
+ help="采集总时长秒 (与 --max-frames 至少一个 > 0)")
371
+ st.add_argument("--max-frames", type=int, default=0,
372
+ help="达到 N 帧后停止 (与 --duration-s 二选一)")
373
+ st.add_argument("--out", help="CSV 输出路径 (默认 lab/streams/ndtdbg_stream_<ts>.csv)")
374
+ st.add_argument("--decode", choices=["hex", "int16le", "uint16le", "int8", "uint8"],
375
+ default="hex", help="decoded 附加列的解码模式 (默认仅 raw hex)")
376
+ st.add_argument("--retry", type=int, default=2,
377
+ help="每帧每寄存器重试次数 (全 FF / IO 错误, 默认 2)")
378
+ st.add_argument("--fail-threshold", type=int, default=None,
379
+ help="连续 IO_FAIL 熔断阈值 (默认 lab.yaml ndtdbg.stream.fail_threshold 或 50; 0=不熔断)")
380
+ st.add_argument("--require-target", action="store_true",
381
+ help="采集前探测 0x10, 无有效芯片应答则 TARGET_MISMATCH")
252
382
  return p
253
383
 
254
384
 
@@ -273,6 +403,11 @@ def main(argv=None) -> None:
273
403
  cmd_poll(io, args.duration_ms, args.interval_ms, args.max_frames, args.full)
274
404
  elif args.cmd == "flash-bin":
275
405
  cmd_flash_bin(io, config, args.file, args.erase_ms, args.confirm)
406
+ elif args.cmd == "stream":
407
+ specs = [parse_reg_spec(s) for s in args.regs]
408
+ cmd_stream(io, config, specs, args.interval_ms, args.duration_s,
409
+ args.max_frames, args.out, args.decode, args.retry,
410
+ args.fail_threshold, args.require_target)
276
411
  else: # pragma: no cover
277
412
  fail("CONFIG_ERROR", f"unknown cmd: {args.cmd}")
278
413
  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.0).
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.0)" -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