From 078ca04941f070fbc58c598ba8f7b1496a9c3537 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 23:24:14 +0800 Subject: [PATCH] fix: support device-pod flash-vector launch --- docs/reference/device-pod.md | 2 + skills/device-pod-cli/SKILL.md | 35 ++++- .../device-pod-cli/assets/device-host-cli.mjs | 135 +++++++++++++++++- tools/device-pod-cli.mjs | 46 ++++-- 4 files changed, 204 insertions(+), 14 deletions(-) diff --git a/docs/reference/device-pod.md b/docs/reference/device-pod.md index d4a51b41..64d6e125 100644 --- a/docs/reference/device-pod.md +++ b/docs/reference/device-pod.md @@ -60,6 +60,8 @@ HWLAB coder / code agent runner 镜像必须通过正式 CI/CD 预装 device-pod - **device-pod-cli 入口**:runner 内必须预装 `device-pod-cli` skill,稳定入口为 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs`,其实现指向 `/app/tools/device-pod-cli.mjs`。code agent 应通过该 CLI 读取 workspace 下 `.device-pod/.json`,再走 cloud-api/gateway/device-host-cli 调用设备能力。 - **device-host-cli 资产**:runner 内必须随 `device-pod-cli` skill 打包 Windows 侧自包含 host CLI 资产,稳定路径为 `/app/skills/device-pod-cli/assets/device-host-cli.mjs`。新 Windows 硬件 PC 接入时,不运行独立安装器;code agent 使用预装 cmd 透传入口把该资产发送到目标 workspace 的 `tools\device-host-cli.mjs`,再通过同一 cmd 透传入口执行 `node tools\device-host-cli.mjs health` 验证。 +`device-host-cli` 不是一次性预装脚本,而是 HWLAB 内部 code agent 可继续热开发的 host 侧工具。DeepSeek、Codex runner 或其他 HWLAB 内部 code agent 在真实硬件闭环中遇到 Keil、debug probe、串口、文件工作区或硬件启动路径不顺手时,优先在目标 Windows workspace 的 `tools\device-host-cli.mjs` 上新增或修复具名设备能力,并通过 `device-pod-cli -> cloud-api -> gateway -> device-host-cli` 的真实链路热验证;验证通过后再把同一实现回填到 `/app/skills/device-pod-cli/assets/device-host-cli.mjs` 对应的 HWLAB repo 资产,进入下一次 CI/CD 预装。不得因为 host CLI 暂时不顺手而把 `device-pod-cli` 退化为泛化 cmd/shell 入口。 + 目标 Windows workspace 的最小布局为: ```text diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index 33639392..779506aa 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -38,6 +38,12 @@ device pod, debug probe control, or I/O probe reads such as UART boot logs. - `device-host-cli` is expected to be self-contained on the user PC. Keil, serial-monitor, mklink, and other skills can be implementation references, but are not runtime dependencies of this HWLAB runner skill. +- `device-host-cli` is not just a copied preinstall script. It is the hot + development tool that HWLAB internal code agents, including DeepSeek-backed + runners, may keep improving on the target Windows workspace when hardware, + Keil, probe, UART, or filesystem operations are not smooth enough. Hot fixes + must be validated through the real device-pod/gateway route first, then copied + back into the HWLAB repo asset for the next CI/CD preinstall. ## Windows Host CLI Bootstrap @@ -77,6 +83,26 @@ After that, `device-pod-cli` should use the profile's `hostCli`, normally `node tools\device-host-cli.mjs`, and should not depend on any Windows skill directory at runtime. +## Host CLI Hot Development + +When a profiled operation is awkward or missing, prefer improving the Windows +workspace copy of `tools\device-host-cli.mjs` instead of bypassing the device-pod +model with arbitrary cmd snippets. The intended loop is: + +```text +edit tools\device-host-cli.mjs in the Windows device-pod workspace +node tools\device-host-cli.mjs health +node tools\device-host-cli.mjs +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs :... +copy the validated change back to skills/device-pod-cli/assets/device-host-cli.mjs +``` + +The host CLI should stay self-contained and named-operation oriented: add +`debug-probe launch-flash` or `io-probe uart/1 read-after-launch-flash` style +device semantics, not a generic shell escape. If gateway/cmd transfer itself is +the friction, fix `tran.mjs` or the gateway transport separately instead of +embedding transport workarounds into `device-host-cli`. + ## Commands All commands emit JSON and must remain short-running. Use `build start` / @@ -93,14 +119,17 @@ node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:wo node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --approved --reason "DEV smoke" node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download status +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe launch-flash --approved --reason "manual flash-vector launch" node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 ports -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000 +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read-after-launch-flash --approved --reason "boot log capture" ``` ## Safety -- Mutating operations such as workspace patching, download, reset, and I/O write - require `--approved --reason ` before the CLI dispatches anything. +- Mutating operations such as workspace patching, download, reset, + `launch-flash`, `read-after-launch-flash`, and I/O write require + `--approved --reason ` before the CLI dispatches anything. - `io-probe:/inner/...` is intentionally distinct from external physical I/O paths such as `io-probe:/uart/1`; do not use internal target state as evidence for external UART/GPIO observations. diff --git a/skills/device-pod-cli/assets/device-host-cli.mjs b/skills/device-pod-cli/assets/device-host-cli.mjs index 6da0f489..26cdb73a 100644 --- a/skills/device-pod-cli/assets/device-host-cli.mjs +++ b/skills/device-pod-cli/assets/device-host-cli.mjs @@ -555,6 +555,29 @@ async function pyocdArgs(profile, commandArgs) { return { pyocd: tools.pyocd.path, args }; } +async function pyocdCommanderArgs(profile, commands, options = {}) { + const tools = await toolInfo(profile); + if (!tools.pyocd.found) throw new Error('pyOCD executable not found'); + const debug = profile.debugInterface || {}; + const args = ['commander']; + if (debug.probeUid) args.push('-u', debug.probeUid); + if (debug.pyocdTarget) args.push('-t', debug.pyocdTarget); + const connectMode = options['connect-mode'] || options.connectMode || debug.launchConnectMode || 'attach'; + if (connectMode) args.push('-M', connectMode); + if (debug.frequency) args.push('-f', debug.frequency); + args.push('-W'); + for (const command of commands) args.push('-c', command); + return { pyocd: tools.pyocd.path, args }; +} + +function parseFlashVectors(text, flashBase) { + const normalizedBase = String(flashBase || '').toLowerCase().replace(/^0x/u, ''); + const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = String(text || '').toLowerCase().match(new RegExp(`${escapedBase}\\s*:\\s*([0-9a-f]{8})\\s+([0-9a-f]{8})`, 'u')); + if (!match) throw new Error(`unable to parse flash vectors at ${flashBase}`); + return { initialSp: `0x${match[1]}`, resetHandler: `0x${match[2]}` }; +} + async function debugStatus() { const profile = loadProfile(); const tools = await toolInfo(profile); @@ -584,6 +607,45 @@ async function resetRun() { ok('debug-probe.reset', { exitCode: result.exitCode, success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr }); } +async function launchFlash(options = {}) { + const profile = loadProfile(); + const flashBase = String(options['flash-base'] || options.flashBase || profile.debugInterface?.flashBase || profile.target?.flashBase || '0x08000000'); + const skipHardwareReset = Boolean(options['skip-hardware-reset'] || options.skipHardwareReset); + const prepareCommand = skipHardwareReset ? 'halt' : 'reset halt HARDWARE'; + const timeoutMs = Number(options['timeout-ms'] || options.timeoutMs || 60000); + const read = await pyocdCommanderArgs(profile, [prepareCommand, `read32 ${flashBase} 8`], options); + const vectorResult = await runCapture(read.pyocd, read.args, { timeoutMs }); + if (vectorResult.exitCode !== 0) throw new Error(`pyOCD vector read failed: ${shortTail(vectorResult.stdout || vectorResult.stderr)}`); + const vectorText = `${vectorResult.stdout}\n${vectorResult.stderr}`; + const vectors = parseFlashVectors(vectorText, flashBase); + const launchCommands = [ + prepareCommand, + `write32 0xE000ED08 ${flashBase}`, + `wreg sp ${vectors.initialSp}`, + `wreg pc ${vectors.resetHandler}`, + 'wreg xpsr 0x01000000', + 'wreg primask 0', + 'wreg faultmask 0', + 'wreg basepri 0', + 'go', + ]; + const launch = await pyocdCommanderArgs(profile, launchCommands, options); + const launchResult = await runCapture(launch.pyocd, launch.args, { timeoutMs }); + ok('debug-probe.launch-flash', { + success: launchResult.exitCode === 0, + exitCode: launchResult.exitCode, + flashBase, + vectorTableRegister: '0xE000ED08', + prepareCommand, + skipHardwareReset, + initialSp: vectors.initialSp, + resetHandler: vectors.resetHandler, + stdout: launchResult.stdout, + stderr: launchResult.stderr, + vectorRead: { exitCode: vectorResult.exitCode, stdout: vectorResult.stdout, stderr: vectorResult.stderr }, + }); +} + async function serialPorts() { const script = "Get-CimInstance Win32_SerialPort | Select-Object DeviceID,Name,PNPDeviceID | ConvertTo-Json -Compress"; const result = await runPowerShell(script, 15000); @@ -627,6 +689,73 @@ async function serialRead(uartId, options) { ok('io-probe.uart.read', { uart, durationMs, exitCode: result.exitCode, content, bytes: Buffer.byteLength(content), logFile, stderr: result.stderr }); } +async function serialReadAfterLaunchFlash(uartId, options) { + const profile = loadProfile(); + const uart = resolveUart(profile, uartId); + const tools = await toolInfo(profile); + if (!tools.pyocd.found) throw new Error('pyOCD executable not found'); + const debug = profile.debugInterface || {}; + const durationMs = Number(options['duration-ms'] || options.durationMs || 5000); + const flashBase = String(options['flash-base'] || options.flashBase || debug.flashBase || profile.target?.flashBase || '0x08000000'); + const skipHardwareReset = Boolean(options['skip-hardware-reset'] || options.skipHardwareReset); + const prepareCommand = skipHardwareReset ? 'halt' : 'reset halt HARDWARE'; + const connectMode = options['connect-mode'] || options.connectMode || debug.launchConnectMode || 'attach'; + const script = [ + '$ErrorActionPreference = \'Stop\';', + `$portName = ${psQuote(uart.port)};`, + `$baud = ${Number(uart.baudRate || 115200)};`, + `$durationMs = ${durationMs};`, + `$pyocd = ${psQuote(tools.pyocd.path)};`, + `$uid = ${psQuote(debug.probeUid || '')};`, + `$target = ${psQuote(debug.pyocdTarget || '')};`, + `$frequency = ${psQuote(debug.frequency || '')};`, + `$connectMode = ${psQuote(connectMode || '')};`, + `$flashBase = ${psQuote(flashBase)};`, + `$prepareCommand = ${psQuote(prepareCommand)};`, + '$common = @(\'commander\');', + 'if ($uid) { $common += @(\'-u\', $uid) }', + 'if ($target) { $common += @(\'-t\', $target) }', + 'if ($connectMode) { $common += @(\'-M\', $connectMode) }', + 'if ($frequency) { $common += @(\'-f\', $frequency) }', + '$common += @(\'-W\');', + '$port = [System.IO.Ports.SerialPort]::new($portName, $baud, [System.IO.Ports.Parity]::None, 8, [System.IO.Ports.StopBits]::One);', + '$port.Encoding = [System.Text.UTF8Encoding]::new($false);', + '$port.ReadTimeout = 200;', + 'try {', + ' $port.Open();', + ' $port.DiscardInBuffer();', + ' $port.Close();', + ' $vectorOutput = & $pyocd @common -c $prepareCommand -c "read32 $flashBase 8" 2>&1;', + ' $vectorExitCode = $LASTEXITCODE;', + ' $vectorText = ($vectorOutput | Out-String);', + ' if ($vectorExitCode -ne 0) { throw "pyOCD vector read failed: $vectorText" }', + ' $escapedBase = [regex]::Escape($flashBase.ToLower().Replace("0x", ""));', + ' $match = [regex]::Match($vectorText.ToLower(), "$escapedBase\\s*:\\s*([0-9a-f]{8})\\s+([0-9a-f]{8})");', + ' if (-not $match.Success) { throw "Unable to parse flash vectors from pyOCD output: $vectorText" }', + ' $initialSp = "0x" + $match.Groups[1].Value;', + ' $resetHandler = "0x" + $match.Groups[2].Value;', + ' $launchOutput = & $pyocd @common -c $prepareCommand -c "write32 0xE000ED08 $flashBase" -c "wreg sp $initialSp" -c "wreg pc $resetHandler" -c "wreg xpsr 0x01000000" -c "wreg primask 0" -c "wreg faultmask 0" -c "wreg basepri 0" -c "go" 2>&1;', + ' $launchExitCode = $LASTEXITCODE;', + ' $launchText = ($launchOutput | Out-String);', + ' if ($launchExitCode -ne 0) { throw "pyOCD flash-vector launch failed: $launchText" }', + ' $port.Open();', + ' Start-Sleep -Milliseconds $durationMs;', + ' $text = $port.ReadExisting();', + ' $bytes = [System.Text.Encoding]::UTF8.GetBytes($text);', + ' [pscustomobject]@{ contentB64 = [Convert]::ToBase64String($bytes); flashBase = $flashBase; vectorTableRegister = "0xE000ED08"; prepareCommand = $prepareCommand; initialSp = $initialSp; resetHandler = $resetHandler; vectorExitCode = $vectorExitCode; launchExitCode = $launchExitCode; vectorStdout = $vectorText; launchStdout = $launchText } | ConvertTo-Json -Depth 5 -Compress | Write-Output;', + '} finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }', + ].join(' '); + const result = await runPowerShell(script, Math.max(15000, durationMs + 20000)); + if (result.exitCode !== 0) throw new Error(`uart read-after-launch-flash failed: ${shortTail(result.stdout || result.stderr)}`); + const payload = JSON.parse(result.stdout.trim()); + const content = payload.contentB64 ? Buffer.from(payload.contentB64, 'base64').toString('utf8') : ''; + const logDir = path.join(stateRoot(profile.__workspaceRoot), 'serial'); + ensureDir(logDir); + const logFile = path.join(logDir, `${uart.id.replace(/\//g, '_')}.jsonl`); + fs.appendFileSync(logFile, `${JSON.stringify({ ts: nowIso(), direction: 'read-after-launch-flash', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, content })}\n`, 'utf8'); + ok('io-probe.uart.read-after-launch-flash', { uart, durationMs, exitCode: result.exitCode, content, bytes: Buffer.byteLength(content), logFile, stderr: result.stderr, launch: { flashBase: payload.flashBase, vectorTableRegister: payload.vectorTableRegister, prepareCommand: payload.prepareCommand, initialSp: payload.initialSp, resetHandler: payload.resetHandler, vectorExitCode: payload.vectorExitCode, launchExitCode: payload.launchExitCode, vectorStdout: payload.vectorStdout, launchStdout: payload.launchStdout } }); +} + async function serialWrite(uartId, options) { const profile = loadProfile(); const uart = resolveUart(profile, uartId); @@ -654,8 +783,8 @@ function usage() { 'node tools/device-host-cli.mjs workspace rg [path]', 'node tools/device-host-cli.mjs workspace apply-patch [base] --patch-b64 ', 'node tools/device-host-cli.mjs workspace build start|status [jobId]', - 'node tools/device-host-cli.mjs debug-probe status|chip-id|bind|download|reset', - 'node tools/device-host-cli.mjs io-probe uart/1 ports|read|write [args]' + 'node tools/device-host-cli.mjs debug-probe status|chip-id|bind|download|reset|launch-flash', + 'node tools/device-host-cli.mjs io-probe uart/1 ports|read|read-after-launch-flash|write [args]' ] }); } @@ -688,12 +817,14 @@ async function main() { if (sub === 'status') return readJobStatus('keil-download', rest[1]); } if (command === 'reset') return await resetRun(); + if (command === 'launch-flash') return await launchFlash(parseArgs(rest)); } if (group === 'io-probe') { const uartId = command; const sub = rest[0] || 'read'; if (sub === 'ports') return await serialPorts(); if (sub === 'read') return await serialRead(uartId, parseArgs(rest.slice(1))); + if (sub === 'read-after-launch-flash') return await serialReadAfterLaunchFlash(uartId, parseArgs(rest.slice(1))); if (sub === 'write') return await serialWrite(uartId, parseArgs(rest.slice(1))); } throw new Error(`unsupported command: ${process.argv.slice(2).join(' ')}`); diff --git a/tools/device-pod-cli.mjs b/tools/device-pod-cli.mjs index 9aa49a5a..0f076e10 100644 --- a/tools/device-pod-cli.mjs +++ b/tools/device-pod-cli.mjs @@ -93,25 +93,53 @@ function cmdQuote(value) { return `"${text.replace(/"/g, '\\"')}"`; } +const CONTROL_OPTION_KEYS = new Set([ + 'approved', + 'reason', + 'dryRun', + 'full', + 'timeoutMs', + 'traceId', + 'podId', + 'patch', + 'patchB64', +]); + +function camelToKebab(value) { + return String(value).replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`); +} + +function hostOptionArgs(parsed) { + const out = []; + for (const [key, value] of Object.entries(parsed)) { + if (key === '_' || CONTROL_OPTION_KEYS.has(key) || value === undefined || value === false) continue; + const name = `--${camelToKebab(key)}`; + if (value === true) out.push(name); + else out.push(name, String(value)); + } + return out; +} + async function buildHostCommand(selector, parsed) { const args = []; + const options = hostOptionArgs(parsed); const operation = parsed._[0] || (selector.surface === 'workspace' ? 'ls' : selector.surface === 'debug-probe' ? 'status' : 'read'); const mutating = isMutating(selector.surface, operation); if (mutating) requireApproval(parsed, `${selector.surface}.${operation}`); if (selector.surface === 'workspace') { const workspacePath = selector.path.replace(/^\/+/, '') || '.'; - if (operation === 'ls' || operation === 'cat') args.push('workspace', operation, parsed._[1] ? path.posix.join(workspacePath, parsed._[1]) : workspacePath, ...parsed._.slice(2)); - else if (operation === 'rg') args.push('workspace', 'rg', parsed._[1] || '', workspacePath, ...parsed._.slice(2)); + if (operation === 'ls' || operation === 'cat') args.push('workspace', operation, parsed._[1] ? path.posix.join(workspacePath, parsed._[1]) : workspacePath, ...parsed._.slice(2), ...options); + else if (operation === 'rg') args.push('workspace', 'rg', parsed._[1] || '', workspacePath, ...parsed._.slice(2), ...options); else if (operation === 'apply-patch') args.push('workspace', 'apply-patch', workspacePath, '--patch-b64', Buffer.from(await resolvePatchText(parsed), 'utf8').toString('base64')); - else if (operation === 'build') args.push('workspace', 'build', ...(parsed._.slice(1).length ? parsed._.slice(1) : ['start'])); + else if (operation === 'build') args.push('workspace', 'build', ...(parsed._.slice(1).length ? parsed._.slice(1) : ['start']), ...options); else throw new Error(`unsupported workspace command: ${operation}`); } else if (selector.surface === 'debug-probe') { - args.push('debug-probe', operation, ...parsed._.slice(1)); + args.push('debug-probe', operation, ...parsed._.slice(1), ...options); } else if (selector.surface === 'io-probe') { const probePath = selector.path.replace(/^\/+/, '') || 'uart/1'; if (probePath.startsWith('inner/')) throw new Error(`unsupported io-probe path in MVP: ${probePath}`); - args.push('io-probe', probePath, operation, ...parsed._.slice(1)); + args.push('io-probe', probePath, operation, ...parsed._.slice(1), ...options); } else { throw new Error(`unsupported surface: ${selector.surface}`); } @@ -120,8 +148,8 @@ async function buildHostCommand(selector, parsed) { function isMutating(surface, operation) { return (surface === 'workspace' && operation === 'apply-patch') - || (surface === 'debug-probe' && (operation === 'download' || operation === 'reset')) - || (surface === 'io-probe' && operation === 'write'); + || (surface === 'debug-probe' && (operation === 'download' || operation === 'reset' || operation === 'launch-flash')) + || (surface === 'io-probe' && (operation === 'write' || operation === 'read-after-launch-flash')); } function requireApproval(parsed, operation) { if (parsed.approved === true && typeof parsed.reason === 'string' && parsed.reason.trim()) return; @@ -195,8 +223,8 @@ function help() { 'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/firmware cat main.c', 'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/ apply-patch --approved --reason "DEV edit" < patch.diff', 'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start|status ', - 'node tools/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id|status|download|reset --approved --reason "DEV smoke"', - 'node tools/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 ports|read|write --approved --reason "DEV io"' + 'node tools/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id|status|download|reset|launch-flash --approved --reason "DEV smoke"', + 'node tools/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 ports|read|read-after-launch-flash|write --approved --reason "DEV io"' ] }); }