Merge pull request #514 from pikasTech/fix/device-pod-vector-launch-20260527

fix: 支持 device-pod Flash 向量启动抓串口
This commit is contained in:
Lyon
2026-05-27 23:27:07 +08:00
committed by GitHub
4 changed files with 204 additions and 14 deletions
+2
View File
@@ -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/<devicePodId>.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
+32 -3
View File
@@ -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 <new named operation>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs <devicePodId>:... <same operation>
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 <jobId>
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 <text>` 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 <text>` 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.
@@ -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 <pattern> [path]',
'node tools/device-host-cli.mjs workspace apply-patch [base] --patch-b64 <base64>',
'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(' ')}`);
+37 -9
View File
@@ -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 <jobId>',
'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"'
] });
}