fix: migrate Keil hwpod compiler binding

This commit is contained in:
Codex Agent
2026-06-05 20:47:37 +08:00
parent 3045245713
commit 2292ba5189
3 changed files with 194 additions and 3 deletions
+9 -1
View File
@@ -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 可执行的基础动作。第一版最小集合:
+47
View File
@@ -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");
+138 -2
View File
@@ -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";