diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index 57a899d7..b3f7c3f9 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -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 可执行的基础动作。第一版最小集合: diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 131e3fd9..631abc38 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -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"); diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 5395224c..681e1ea4 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -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; type ParsedArgs = Record & { _: 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";