fix: migrate Keil hwpod compiler binding
This commit is contained in:
@@ -81,9 +81,13 @@ spec:
|
||||
workspace:
|
||||
path: /workspace/project
|
||||
toolchain: keil-mdk
|
||||
keilProject: firmware/demo.uvprojx
|
||||
keilTarget: Debug
|
||||
debugProbe:
|
||||
type: stlink
|
||||
type: daplink
|
||||
adapter: keil
|
||||
probeUid: 3FD750C63E342E24
|
||||
programBackend: keil
|
||||
ioProbe:
|
||||
uart:
|
||||
id: uart/1
|
||||
@@ -101,6 +105,10 @@ spec:
|
||||
- `spec.nodeBinding.nodeId` 必须存在。
|
||||
- `spec.workspace.path` 必须存在。
|
||||
|
||||
Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil`、`mdk` 或 `uv4` 时,`hwpod-compiler-cli` 可以直接从 `hwpod-spec` 生成 `debug.build` / `debug.download` 的 node-side command。`spec.workspace.keilProject` 或 `spec.workspace.projectPath` 指向 `.uvprojx`,相对路径按 `spec.workspace.path` 解析;`spec.workspace.keilTarget` 或 `spec.workspace.targetName` 指向 Keil target。`spec.debugProbe.probeUid` 用于固定 DAP-Link/CMSIS-DAP 选择,`spec.debugProbe.programBackend` 默认按 Keil UV4 工程链路下载。下载前若存在 `probeUid`,compiler 会先生成 Keil probe-binding 写回命令,再生成 `program --program-backend keil` 命令,以保持旧 host profile 中 probe 绑定与 Keil 下载语义在新 hwpod 概念系统内收敛。
|
||||
|
||||
`spec.workspace.buildCommand`、`spec.debugProbe.downloadCommand` 和 `spec.debugProbe.resetCommand` 仍是调试覆盖出口;标准 case 和 Code Agent runner 不应依赖这些字段长期手写 Keil 主流程。旧 `device-host-cli` / device pod profile 不再作为 compiler 输入权威,相关工程、target、probe 和下载 backend 信息必须迁入 `hwpod-spec`。
|
||||
|
||||
## hwpod-node-ops
|
||||
|
||||
`hwpod-node-ops` 不表达用户意图,只表达 node 可执行的基础动作。第一版最小集合:
|
||||
|
||||
@@ -75,6 +75,53 @@ test("hwpod-compiler-cli compiles workspace-local spec into node ops", async ()
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hwpod-compiler-cli generates Keil build and download commands from structured hwpod-spec", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-keil-compiler-"));
|
||||
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
||||
try {
|
||||
await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "node-d601-f103-v2", "--workspace", "F:\\Work\\D601-HWLAB"], { now: () => NOW });
|
||||
await runHwpodCtl(["spec", "set", "spec.workspace.toolchain", "keil-mdk", "--spec", specPath], { now: () => NOW });
|
||||
await runHwpodCtl(["spec", "set", "spec.workspace.keilProject", "projects/01_baseline/Projects/MDK-ARM/atk_f103.uvprojx", "--spec", specPath], { now: () => NOW });
|
||||
await runHwpodCtl(["spec", "set", "spec.workspace.keilTarget", "USART", "--spec", specPath], { now: () => NOW });
|
||||
await runHwpodCtl(["spec", "set", "spec.workspace.keilCliPath", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "--spec", specPath], { now: () => NOW });
|
||||
await runHwpodCtl(["spec", "set", "spec.debugProbe.type", "daplink", "--spec", specPath], { now: () => NOW });
|
||||
await runHwpodCtl(["spec", "set", "spec.debugProbe.adapter", "keil", "--spec", specPath], { now: () => NOW });
|
||||
await runHwpodCtl(["spec", "set", "spec.debugProbe.probeUid", "3FD750C63E342E24", "--spec", specPath], { now: () => NOW });
|
||||
await runHwpodCtl(["spec", "set", "spec.debugProbe.probeName", "CherryUSB MicroLink CMSIS-DAP", "--spec", specPath], { now: () => NOW });
|
||||
|
||||
const build = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.build"], { now: () => NOW });
|
||||
assert.equal(build.exitCode, 0);
|
||||
assert.equal(build.payload.plan.ops[0].op, "debug.build");
|
||||
assert.equal(build.payload.plan.ops[0].args.target, "USART");
|
||||
assert.match(build.payload.plan.ops[0].args.command, /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py build -p/u);
|
||||
assert.match(build.payload.plan.ops[0].args.command, /F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103\.uvprojx/u);
|
||||
assert.match(build.payload.plan.ops[0].args.command, / -t USART$/u);
|
||||
assert.equal(build.payload.plan.ops[0].args.commandBinding.source, "hwpod-compiler.keil-mdk");
|
||||
assert.equal(build.payload.plan.ops[0].args.commandBinding.action, "build");
|
||||
|
||||
const download = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW });
|
||||
assert.equal(download.exitCode, 0);
|
||||
assert.equal(download.payload.plan.ops[0].op, "debug.download");
|
||||
assert.equal(download.payload.plan.ops[0].args.commandBinding.action, "download");
|
||||
assert.equal(download.payload.plan.ops[0].args.commandBinding.programBackend, "keil");
|
||||
assert.match(download.payload.plan.ops[0].args.command, /project probe-binding set -p/u);
|
||||
assert.match(download.payload.plan.ops[0].args.command, / --target USART /u);
|
||||
assert.match(download.payload.plan.ops[0].args.command, /--probe-uid 3FD750C63E342E24/u);
|
||||
assert.match(download.payload.plan.ops[0].args.command, /--probe-name "CherryUSB MicroLink CMSIS-DAP"/u);
|
||||
assert.match(download.payload.plan.ops[0].args.command, / && py -3 .*keil-cli\.py program -p/u);
|
||||
assert.match(download.payload.plan.ops[0].args.command, / -m daplink --program-backend keil -u 3FD750C63E342E24 -t USART$/u);
|
||||
|
||||
await runHwpodCtl(["spec", "set", "spec.debugProbe.downloadCommand", "custom download command", "--spec", specPath], { now: () => NOW });
|
||||
const override = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW });
|
||||
assert.equal(override.exitCode, 0);
|
||||
assert.equal(override.payload.plan.ops[0].args.command, "custom download command");
|
||||
assert.equal(override.payload.plan.ops[0].args.commandBinding, undefined);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops plan", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-cli-"));
|
||||
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
||||
|
||||
@@ -15,6 +15,7 @@ const CTL_NAME = "hwpod-ctl";
|
||||
const CLI_NAME = "hwpod-cli";
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const BOOLEAN_OPTIONS = new Set(["dryRun", "force", "full", "help", "h", "json", "noAuth"]);
|
||||
const DEFAULT_KEIL_COMMAND_TIMEOUT_MS = 30000;
|
||||
type EnvLike = Record<string, string | undefined>;
|
||||
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||||
type FetchLike = typeof fetch;
|
||||
@@ -321,8 +322,8 @@ function opsForIntent(intent: string, args: any, document: any) {
|
||||
if (intent === "workspace.cat") return [{ op: "workspace.cat", args: { ...common, path: requiredText(args.path, "path") } }];
|
||||
if (intent === "workspace.rg") return [{ op: "workspace.rg", args: { ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true } }];
|
||||
if (intent === "workspace.apply-patch") return [{ op: "workspace.apply-patch", args: clean({ ...common, patch: text(args.patch), patchBase64: text(args.patchBase64), reason: text(args.reason) }) }];
|
||||
if (intent === "debug.build") return [{ op: "debug.build", args: clean({ ...common, target: text(args.target), command: text(args.command) || text(document.spec.workspace.buildCommand), reason: text(args.reason) }) }];
|
||||
if (intent === "debug.download") return [{ op: "debug.download", args: clean({ ...common, artifact: text(args.artifact), target: text(args.target), command: text(args.command) || text(document.spec.debugProbe.downloadCommand), reason: text(args.reason) }) }];
|
||||
if (intent === "debug.build") return [{ op: "debug.build", args: debugBuildArgs(common, args, document) }];
|
||||
if (intent === "debug.download") return [{ op: "debug.download", args: debugDownloadArgs(common, args, document) }];
|
||||
if (intent === "debug.reset") return [{ op: "debug.reset", args: clean({ ...common, mode: text(args.mode), command: text(args.command) || text(document.spec.debugProbe.resetCommand), reason: text(args.reason) }) }];
|
||||
if (intent === "io.uart.read") return [{ op: "io.uart.read", args: clean({ ...common, port: text(args.port) || "uart1", maxBytes: numberValue(args.maxBytes), since: text(args.since) }) }];
|
||||
if (intent === "io.uart.write") return [{ op: "io.uart.write", args: clean({ ...common, port: text(args.port) || "uart1", data: requiredText(args.data, "data") }) }];
|
||||
@@ -341,6 +342,141 @@ function commonOpArgs(document: any) {
|
||||
};
|
||||
}
|
||||
|
||||
function debugBuildArgs(common: any, args: any, document: any) {
|
||||
const explicitCommand = text(args.command) || text(document.spec.workspace.buildCommand);
|
||||
const generated = explicitCommand ? null : keilCommandForIntent("debug.build", args, document);
|
||||
return clean({
|
||||
...common,
|
||||
target: text(args.target) || generated?.target,
|
||||
command: explicitCommand || generated?.command,
|
||||
commandBinding: generated?.binding,
|
||||
timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs,
|
||||
reason: text(args.reason)
|
||||
});
|
||||
}
|
||||
|
||||
function debugDownloadArgs(common: any, args: any, document: any) {
|
||||
const explicitCommand = text(args.command) || text(document.spec.debugProbe.downloadCommand);
|
||||
const generated = explicitCommand ? null : keilCommandForIntent("debug.download", args, document);
|
||||
return clean({
|
||||
...common,
|
||||
artifact: text(args.artifact),
|
||||
target: text(args.target) || generated?.target,
|
||||
command: explicitCommand || generated?.command,
|
||||
commandBinding: generated?.binding,
|
||||
timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs,
|
||||
reason: text(args.reason)
|
||||
});
|
||||
}
|
||||
|
||||
function keilCommandForIntent(intent: "debug.build" | "debug.download", args: any, document: any) {
|
||||
const workspace = objectValue(document.spec.workspace);
|
||||
const debugProbe = objectValue(document.spec.debugProbe);
|
||||
const toolchain = text(workspace.toolchain).toLowerCase();
|
||||
if (!toolchain || !["keil", "keil-mdk", "mdk", "uv4"].includes(toolchain)) return null;
|
||||
|
||||
const project = hwpodKeilProject(args, document);
|
||||
const target = hwpodKeilTarget(args, document);
|
||||
const keilCliPath = text(args.keilCliPath) || text(workspace.keilCliPath) || text(debugProbe.keilCliPath) || text(objectValue(document.spec.tooling).keilCliPath) || "keil-cli.py";
|
||||
const pythonCommand = text(args.pythonCommand) || text(workspace.pythonCommand) || text(debugProbe.pythonCommand) || text(objectValue(document.spec.tooling).pythonCommand) || "py -3";
|
||||
const timeoutMs = numberValue(args.timeoutMs) ?? numberValue(workspace.keilCommandTimeoutMs) ?? numberValue(debugProbe.keilCommandTimeoutMs) ?? DEFAULT_KEIL_COMMAND_TIMEOUT_MS;
|
||||
const commonTokens = [...splitCommandWords(pythonCommand), keilCliPath];
|
||||
if (intent === "debug.build") {
|
||||
const command = shellCommand([...commonTokens, "build", "-p", project, ...targetOption(target)]);
|
||||
return {
|
||||
target,
|
||||
command,
|
||||
timeoutMs,
|
||||
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "build", project, target, keilCliPath, pythonCommand })
|
||||
};
|
||||
}
|
||||
|
||||
const probeUid = text(args.probeUid ?? args.probe) || text(debugProbe.probeUid ?? debugProbe.uid);
|
||||
const programmer = text(args.programmer) || text(debugProbe.programmer) || (text(debugProbe.type).toLowerCase() === "daplink" ? "daplink" : "daplink");
|
||||
const programBackend = text(args.programBackend ?? args.backend) || text(debugProbe.programBackend) || (text(debugProbe.adapter).toLowerCase() === "keil" ? "keil" : "keil");
|
||||
const probeName = text(args.probeName) || text(debugProbe.probeName) || text(debugProbe.name);
|
||||
const autoBindUvoptx = debugProbe.autoBindUvoptx !== false && debugProbe.autoBindProbe !== false;
|
||||
const bindCommand = autoBindUvoptx && probeUid
|
||||
? shellCommand([...commonTokens, "project", "probe-binding", "set", "-p", project, ...projectTargetOption(target), "--probe-uid", probeUid, ...probeNameOption(probeName)])
|
||||
: "";
|
||||
const programCommand = shellCommand([
|
||||
...commonTokens,
|
||||
"program",
|
||||
"-p",
|
||||
project,
|
||||
"-m",
|
||||
programmer,
|
||||
"--program-backend",
|
||||
programBackend,
|
||||
...probeOption(probeUid),
|
||||
...targetOption(target)
|
||||
]);
|
||||
return {
|
||||
target,
|
||||
command: bindCommand ? `${bindCommand} && ${programCommand}` : programCommand,
|
||||
timeoutMs,
|
||||
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "download", project, target, keilCliPath, pythonCommand, programmer, programBackend, probeUid, probeName, autoBindUvoptx: Boolean(bindCommand) })
|
||||
};
|
||||
}
|
||||
|
||||
function hwpodKeilProject(args: any, document: any) {
|
||||
const workspace = objectValue(document.spec.workspace);
|
||||
const projectWorkspace = objectValue(document.spec.projectWorkspace);
|
||||
const rawProject = text(args.project ?? args.projectPath) || text(workspace.keilProject) || text(workspace.projectPath) || text(workspace.project) || text(projectWorkspace.projectPath);
|
||||
if (!rawProject) throw cliError("hwpod_keil_project_required", "keil-mdk hwpod-spec requires spec.workspace.keilProject or spec.workspace.projectPath");
|
||||
return resolveWorkspaceRelativePath(text(workspace.path), rawProject);
|
||||
}
|
||||
|
||||
function hwpodKeilTarget(args: any, document: any) {
|
||||
const workspace = objectValue(document.spec.workspace);
|
||||
const projectWorkspace = objectValue(document.spec.projectWorkspace);
|
||||
return text(args.target) || text(workspace.keilTarget) || text(workspace.targetName) || text(workspace.target) || text(projectWorkspace.targetName);
|
||||
}
|
||||
|
||||
function resolveWorkspaceRelativePath(workspacePath: string, candidate: string) {
|
||||
if (!workspacePath || isAbsoluteLikePath(candidate)) return candidate;
|
||||
const separator = /^[A-Za-z]:[\\/]/u.test(workspacePath) || workspacePath.includes("\\") ? "\\" : "/";
|
||||
const base = workspacePath.replace(/[\\/]+$/u, "");
|
||||
const child = separator === "\\" ? candidate.replace(/\//gu, "\\") : candidate.replace(/\\/gu, "/");
|
||||
return `${base}${separator}${child.replace(/^[\\/]+/u, "")}`;
|
||||
}
|
||||
|
||||
function isAbsoluteLikePath(value: string) {
|
||||
return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("\\\\");
|
||||
}
|
||||
|
||||
function targetOption(target: string) {
|
||||
return target ? ["-t", target] : [];
|
||||
}
|
||||
|
||||
function projectTargetOption(target: string) {
|
||||
return target ? ["--target", target] : [];
|
||||
}
|
||||
|
||||
function probeOption(probeUid: string) {
|
||||
return probeUid ? ["-u", probeUid] : [];
|
||||
}
|
||||
|
||||
function probeNameOption(probeName: string) {
|
||||
return probeName ? ["--probe-name", probeName] : [];
|
||||
}
|
||||
|
||||
function splitCommandWords(command: string) {
|
||||
const words = command.trim().split(/\s+/u).filter(Boolean);
|
||||
if (words.length === 0) throw cliError("invalid_keil_python_command", "pythonCommand must not be empty");
|
||||
return words;
|
||||
}
|
||||
|
||||
function shellCommand(tokens: string[]) {
|
||||
return tokens.map(shellArg).join(" ");
|
||||
}
|
||||
|
||||
function shellArg(value: string) {
|
||||
const raw = String(value);
|
||||
if (/^[A-Za-z0-9_./:@\\-]+$/u.test(raw)) return raw;
|
||||
return `"${raw.replace(/(["^&|<>])/gu, "^$1").replace(/%/gu, "%%")}"`;
|
||||
}
|
||||
|
||||
function normalizeIntent(value: unknown) {
|
||||
const intent = requiredText(value, "intent");
|
||||
if (intent === "build") return "debug.build";
|
||||
|
||||
Reference in New Issue
Block a user