fix: support device-pod flash-vector launch

This commit is contained in:
Codex
2026-05-27 23:24:14 +08:00
parent 91f1eb30e6
commit 078ca04941
4 changed files with 204 additions and 14 deletions
+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(' ')}`);