Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions docs/how-to/serve-mcp.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 启动只读 MCP 服务

WaveBench HTTP MCP 服务提供本机或受控网络中的离线信息和 run plan 检查。它不提供 raw SCPI、输出控制或 run 执行。
WaveBench HTTP MCP 服务提供本机或受控网络中的离线信息和只读仪器观察。它不提供 raw SCPI、输出控制或 run 执行。

## 启动服务

Expand All @@ -20,7 +20,23 @@ python -m wavebench mcp serve \
| `GET /tools` | Bearer token | 列出只读工具。 |
| `POST /call`、`POST /mcp` | Bearer token | 调用 MCP 工具或 JSON-RPC 方法。 |

请求体上限为 1 MiB。当前工具仅包含 `run.schema`、`run.check` 和 `capture.inspect`;它们不会连接仪器。`run.check` 只接受项目内的 `plans/*.toml`,`capture.inspect` 只读取项目内的离线采集包。
请求体上限为 1 MiB。所有工具都是只读的,不会改变仪器状态,也不会读取波形。工具的权威列表和元数据以 `GET /tools` 返回为准:

| 工具 | 行为 |
| --- | --- |
| `run.schema` | 返回 run plan schema。 |
| `run.check` | 只接受项目内的 `plans/*.toml`,离线解析并校验 run plan。 |
| `capture.inspect` | 只读取项目内的离线采集包摘要。 |
| `doctor.config` | 对配置中的仪器执行只读 doctor 检查,返回结构化记录。 |
| `scope.observe` | 读取示波器身份、每通道状态快照和输入耦合安全;不读取波形。 |
| `scope.advise` | 基于只读状态快照和调用方给出的 `expected_frequencies_hz` 建议显示/采集参数;不应用建议。 |

`doctor.config`、`scope.observe` 和 `scope.advise` 是实验性工具:开发线已实现并有离线测试,但尚未随正式版本发布,
支持范围不作承诺。

`scope.observe` 和 `scope.advise` 不读取示波器波形。波形摘要、期望值检查和跨通道关系分析需要
先显式读取波形(属于会改变仪器状态的写路径),请在操作者明确执行
[`scope observe --fetch-waveform`](../reference/cli.md) 时进行,不通过 MCP 暴露。

## Verification

Expand Down
34 changes: 32 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,41 @@ python -m wavebench --json <domain> <command> ...
| --- | --- | --- |
| 离线、只读本地文件 | `run schema`、`run check`、`run intent`、`run compare`、`run resume`、`capture inspect`、`capability explain`、`lock status` | 不连接仪器。 |
| 离线且可能写本地文件 | `run template --output`、`run report`、`run report-index` | 不连接仪器,但会创建模板、报告或索引文件。 |
| 连接读取或预检 | `doctor`、状态/身份查询、`run verify` | 会访问配置的仪器,不应改变实验设置。 |
| 可能改变状态或触发采集 | 输出和 setter、`scope auto`/`scope capture`、非 fake TUI、`run plan` | 可能写入仪器、触发采集或切换输出。 |
| 连接读取或预检 | `doctor`、状态/身份查询、`scope observe`(不带 `--fetch-waveform`)、`run verify` | 会访问配置的仪器,不应改变实验设置。 |
| 可能改变状态或触发采集 | 输出和 setter、`scope auto`/`scope capture`、`scope observe --fetch-waveform`、非 fake TUI、`run plan` | 可能写入仪器、触发采集或切换输出。 |

`scope fetch` 读取已有波形,但仍是仪器 I/O;不要把它当作离线命令。每次硬件操作前确认接线、输入阻抗、输出状态和安全限制。WaveBench 不会自动执行 `*RST`,也不会因设置电压、幅度或频率而自动开启输出。

`scope observe` 是实验性命令:开发线已实现并有离线测试,但尚未随正式版本发布,兼容性和支持范围不作承诺。
它默认只读:只查询身份、通道状态快照和输入耦合安全,不读取波形。
`scope observe --fetch-waveform` 是显式写路径,可能停止正在运行的采集、修改波形传输
source/mode/format/points 并打开通道显示;它逐通道读取波形,因此多通道结果不保证来自同一次
acquisition,此时跨通道的相位、相关性、延迟和交点不会被计算(`correlation`、`intersections`
返回 `skipped`,相位为 `null`)。需要驱动可证明的同一次采集时,使用
`scope capture --synchronized`(通道和输出格式要求见其 `--help`)。

期望值检查通过 `--expect <file.toml>` 提供,需要 `--fetch-waveform`:

```toml
[channels.1]
frequency_hz = 1000
frequency_tolerance_ratio = 0.05
vpp_v = 3.3
duty_percent = 50
```

字段名、类型、有限性和取值范围由实现严格校验(`src/wavebench/data/expectations.py` 的 `validate_expectation()`);
拼错字段名会直接报错,不会被静默忽略。上面的 TOML 只示范格式,字段全集以该实现为准。任何输入错误都在
打开仪器会话之前被拒绝,因此不会产生仪器写入。

`--target-cycles` 和 `--target-vertical-divisions` 必须为有限正数,并在加载配置或创建仪器服务
之前校验。生成的 focus 建议使用 `--vertical-scale CHANNEL=V_PER_DIV`;隐藏其他通道的参数为
`--hide-others`。建议不会自动执行。

期望值汇总的 `channels` 保留所有待验收通道。波形读取、安全检查或期望值计算失败的通道标记为
`unavailable`;没有可用检查结果时汇总为 `unavailable`,部分通道已有 `pass`/`warn` 结果时为
`partial`。已确认的 `fail` 仍优先返回 `fail`。未提供期望值或期望值没有可执行指标时保持 `skipped`。

## JSON 输出与退出码

将 `--json` 放在命令行任意位置可请求机器可读输出。成功结果使用 `wavebench.cli.result.v1`,包含 `status`、`exit_code` 和 `result`;错误使用 `wavebench.error.v1`。普通成功输出写入标准输出,普通错误写入标准错误。
Expand Down
138 changes: 138 additions & 0 deletions src/wavebench/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from pathlib import Path
import sys
from tempfile import TemporaryFile
import tomllib
from typing import Any

import numpy as np

Expand Down Expand Up @@ -113,6 +115,8 @@
from .plugins.registry import build_plugin_registry, has_doctor_errors, plugin_doctor_records
from .plugins.scpi import has_scpi_doctor_errors, load_scpi_plugin, probe_scpi_plugin, scpi_plugin_doctor_records
from .services.scope_service import ScopeService
from .services.agent_observe import scope_observe_payload, scope_waveform_report_payload
from .services.agent_advise import scope_advise_from_observation, validate_scope_advice_targets
from .services.source_service import SourceService
from .services.rf_source_service import RfSourceService
from .services.power_service import PowerService
Expand Down Expand Up @@ -611,6 +615,137 @@ def _scope_error_check(args: argparse.Namespace) -> ErrorCheckSpec | None:
raise ConfigError(str(exc)) from exc


def _load_scope_expectations(path: str | None) -> dict[int, dict[str, Any]] | None:
"""读取 --expect 指定的 TOML 期望值文件;校验在读取阶段完成,早于任何仪器 I/O。"""
if path is None:
return None
expectation_path = Path(path)
try:
raw = tomllib.loads(expectation_path.read_bytes().decode("utf-8-sig"))
except OSError as exc:
raise ConfigError(f"failed to read scope expectation file: {expectation_path}") from exc
except tomllib.TOMLDecodeError as exc:
raise ConfigError(f"invalid scope expectation TOML: {expectation_path}") from exc
unknown = sorted(set(raw) - {"channels"})
if unknown:
raise ConfigError(f"unknown scope expectation section(s): {', '.join(unknown)}")
channels = raw.get("channels")
if not isinstance(channels, dict) or not channels:
raise ConfigError("scope expectation file must define a non-empty [channels] table")
expectations: dict[int, dict[str, Any]] = {}
for key, value in channels.items():
try:
channel = int(key)
except (TypeError, ValueError) as exc:
raise ConfigError("scope expectation channel keys must be numbers") from exc
if channel < 1:
raise ConfigError("scope expectation channels must be >= 1")
if not isinstance(value, dict):
raise ConfigError("scope expectation channel entries must be tables")
expectations[channel] = dict(value)
return expectations


def _expectation_frequencies(
expectations: dict[int, dict[str, Any]] | None,
) -> dict[int, float] | None:
if not expectations:
return None
values: dict[int, float] = {}
for channel, expectation in expectations.items():
value = expectation.get("frequency_hz")
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
values[channel] = float(value)
return values or None


def _run_scope_observe(args: argparse.Namespace) -> dict[str, Any]:
target_cycles, target_vertical_divisions = validate_scope_advice_targets(
target_cycles=10.0 if args.target_cycles is None else args.target_cycles,
target_vertical_divisions=(
5.0 if args.target_vertical_divisions is None else args.target_vertical_divisions
),
)
channels = tuple(args.channels) if args.channels else None
expectations = _load_scope_expectations(args.expect)
if not args.fetch_waveform:
if expectations is not None:
raise ConfigError("scope observe --expect requires --fetch-waveform")
return scope_observe_payload(
config_path=args.config,
channels=channels,
allow_50ohm=args.allow_50ohm,
resource=args.resource,
)
observation = scope_waveform_report_payload(
config_path=args.config,
channels=channels,
allow_50ohm=args.allow_50ohm,
expectations=expectations,
resource=args.resource,
)
advice = scope_advise_from_observation(
observation,
expected_frequencies_hz=_expectation_frequencies(expectations),
target_cycles=target_cycles,
target_vertical_divisions=target_vertical_divisions,
)
observation["recommendations"] = advice["recommendations"]
observation["agent_hints"] = advice["agent_hints"]
return observation


def _emit_scope_observe_result(payload: dict[str, Any], *, json_mode: bool) -> None:
if json_mode:
_emit_json_result(payload, status=str(payload.get("status", "ok")))
return
print(
f"status={payload.get('status')} read_only={payload.get('read_only')} "
f"mutates_instrument={payload.get('mutates_instrument')}"
)
identity = payload.get("identity")
if isinstance(identity, dict) and identity.get("status") == "ok":
print(f"idn={identity['data']['idn']}")
for channel_section in payload.get("channels", []) or []:
channel = channel_section.get("channel")
coupling = channel_section.get("coupling", {})
coupling_value = coupling.get("data", {}).get("coupling") if coupling.get("status") == "ok" else "unavailable"
print(f"ch{channel} coupling={coupling_value}")
waveform = channel_section.get("waveform")
if isinstance(waveform, dict) and waveform.get("status") == "ok":
summary = waveform["data"]["summary"]
print(
f"ch{channel} waveform samples={summary.get('samples')} "
f"vpp_v={summary.get('voltage_vpp_v')} mean_v={summary.get('voltage_mean_v')} "
f"frequency_hz={summary.get('frequency_estimate_hz')}"
)
for warning in summary.get("quality_warnings", []) or []:
print(f"ch{channel} quality_warning={warning}")
expectation = channel_section.get("expectation")
if isinstance(expectation, dict):
print(f"ch{channel} expectation={expectation.get('status')}")
for check in expectation.get("data", {}).get("checks", []) or []:
print(
f"ch{channel} check={check.get('metric')} status={check.get('status')} "
f"expected={check.get('expected')} actual={check.get('actual')}"
)
waveform_source = payload.get("waveform_source")
if isinstance(waveform_source, dict) and not waveform_source.get("same_acquisition", True):
print(f"waveform_source same_acquisition=False reason={waveform_source.get('reason')}")
for relationship in payload.get("relationships", []) or []:
channels = relationship.get("channels")
frequency = relationship.get("frequency", {})
print(
f"relationship ch{channels} ratio={frequency.get('ratio_high_over_low')} "
f"phase_deg={relationship.get('phase_degrees_at_left_frequency')}"
)
for recommendation in payload.get("recommendations", []) or []:
command = recommendation.get("command") or recommendation.get("action")
print(f"recommendation {recommendation.get('id')} priority={recommendation.get('priority')} {command}")
for warning in payload.get("warnings", []) or []:
print(f"warning={warning}")


def _scope_channel_display_request(args: argparse.Namespace) -> ScopeChannelDisplayRequest:
try:
return ScopeChannelDisplayRequest(
Expand Down Expand Up @@ -1969,6 +2104,9 @@ def _main(argv: list[str] | None = None) -> int:
print(f"summary={result.summary_path}")
return 0
if args.domain == "scope":
if args.command == "observe":
_emit_scope_observe_result(_run_scope_observe(args), json_mode=args.json)
return 0
service = _load_service(args)
if args.command == "idn":
print(service.idn())
Expand Down
40 changes: 40 additions & 0 deletions src/wavebench/cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,46 @@ def add_trace_reference(parser: argparse.ArgumentParser) -> None:
fetch.add_argument("--allow-50ohm", action="store_true", help="Explicitly allow scope input coupling that may be 50 ohm; default requires high impedance")
add_runtime_options(fetch)

observe = scope_sub.add_parser(
"observe",
help="Observe scope state; --fetch-waveform explicitly reads waveforms for summaries and checks",
)
observe.add_argument(
"--channel",
dest="channels",
type=int,
action="append",
default=None,
help="Observed analog channel; repeat for multiple channels",
)
observe.add_argument(
"--fetch-waveform",
action="store_true",
help=(
"Explicitly read waveforms. This is a write path: it may stop a running acquisition, "
"change waveform transfer source/mode/format/points and enable channel display"
),
)
observe.add_argument(
"--allow-50ohm",
action="store_true",
help="Explicitly allow scope input coupling that may be 50 ohm; default requires high impedance",
)
observe.add_argument(
"--expect",
default=None,
metavar="PATH",
help="TOML file with per-channel [channels.N] expectation checks; requires --fetch-waveform",
)
observe.add_argument("--target-cycles", type=float, default=None, help="Target cycles for advice, default 10")
observe.add_argument(
"--target-vertical-divisions",
type=float,
default=None,
help="Target vertical divisions for advice, default 5",
)
add_runtime_options(observe)

capture = scope_sub.add_parser("capture", help="Capture waveform data into an acquisition package")
capture.add_argument("--channel", type=int, action="append", default=None, help="Capture channel; repeat for multiple channels")
capture.add_argument("--label", default="capture")
Expand Down
Loading
Loading