@typecad/framework-zephyr 1.0.0-alpha.15 → 1.0.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -333,14 +333,24 @@ function buildLaunchConfig(o, gdbScriptRel, openOcdCfgRel, method) {
333
333
  // thb (the first user Continue stops at main()) but hands the run/stop
334
334
  // transition to cortex-debug.
335
335
  //
336
- // Paths use forward slashes ${workspaceFolder} on Windows produces
337
- // backslashes that GDB interprets as escape sequences (\t → tab, etc.).
338
- const ws = o.workspaceRoot.replace(/\\/g, '/');
336
+ // Paths in GDB commands must be RELATIVE (resolved against GDB's own
337
+ // working directory), never ${workspaceFolder} literals or absolute paths:
338
+ // - cortex-debug passes command strings through verbatim — no variable
339
+ // expansion in postAttachCommands (verified against its gdb.ts) — so a
340
+ // ${workspaceFolder} would reach GDB as a literal.
341
+ // - Even where VS Code expands it, on Windows the expansion carries
342
+ // backslashes that GDB reads as escape sequences (\t → tab, etc.).
343
+ // - An absolute path bakes the generating machine's checkout location
344
+ // into launch.json and breaks on every other clone/checkout.
345
+ // cortex-debug spawns GDB with cwd from the config's `cwd` field
346
+ // (${workspaceFolder} above), so a relative path IS the workspace root —
347
+ // `set directories .` adds exactly the directory the old absolute form did,
348
+ // and `source <rel>` finds the frame-filter script.
339
349
  // The Xtensa flash-mapping commands are ESP32-specific (app flash isn't
340
350
  // readable until the bootloader maps it); ARM targets drop them.
341
351
  const isEsp32Target = o.target.split('/')[0].startsWith('esp32');
342
352
  const postAttachCommands = [
343
- `set directories ${ws}`,
353
+ 'set directories .',
344
354
  'set remote hardware-watchpoint-limit 2',
345
355
  'set remote hardware-breakpoint-limit 2',
346
356
  ...(isEsp32Target ? [
@@ -352,7 +362,7 @@ function buildLaunchConfig(o, gdbScriptRel, openOcdCfgRel, method) {
352
362
  ...(isEsp32Target ? ['c'] : []),
353
363
  ];
354
364
  if (gdbScriptRel) {
355
- postAttachCommands.splice(1, 0, `source ${ws}/${gdbScriptRel}`);
365
+ postAttachCommands.splice(1, 0, `source ${gdbScriptRel}`);
356
366
  }
357
367
  // Probe-method-driven server shape: jlink-runner methods use cortex-debug's
358
368
  // jlink server (needs the device name from the board table); openocd-runner
@@ -79,6 +79,45 @@ export type ProbeResolution = {
79
79
  error: string;
80
80
  };
81
81
  export declare function resolveProbeMethod(zc: Record<string, unknown> | undefined, chip: ZephyrChipDescriptor, purpose?: 'flash' | 'debug'): ProbeResolution;
82
+ /**
83
+ * Run an openocd session against the board's probe config — the shared
84
+ * engine for the pre-flash quiesce and the post-flash SYSRESETREQ (see the
85
+ * call sites in upload()). The config is the probe method's verbatim
86
+ * debugCfg from the board catalog (the same lines `west debug` uses);
87
+ * `commands` are appended after `-f <cfg> -c init`. Returns a flash note on
88
+ * success, undefined when skipped or failed (best-effort by design).
89
+ */
90
+ /**
91
+ * Resolve the openocd binary (plus script search dirs) for the probe session.
92
+ *
93
+ * Resolution order mirrors how west's own openocd runner finds the binary, so
94
+ * the session and `west flash` drive the SAME openocd:
95
+ * 1. $OPENOCD — the variable Zephyr's CMake reads into the build cache.
96
+ * 2. A Zephyr-SDK-hosted install ($ZEPHYR_SDK_INSTALL_DIR, else the
97
+ * discovered west install's SDK). Layout differs by SDK generation —
98
+ * hosttools/openocd/share/openocd/scripts vs hosttools/openocd/scripts —
99
+ * so both script dirs are collected.
100
+ * 3. PATH — Linux distro / conda / micromamba installs (a micromamba-env
101
+ * openocd is the common Linux setup; the SDK layout check alone made the
102
+ * deterministic probe session unreachable there, silently dropping every
103
+ * Linux flash to the racy `west flash` fallback).
104
+ *
105
+ * A non-SDK binary resolves with no explicit -s dirs: it finds its own
106
+ * interface/target scripts via its compiled-in search path. Returns undefined
107
+ * when no openocd can be found (the caller then uses the west fallback).
108
+ *
109
+ * Exported (pure) so the resolution contract is unit-testable without
110
+ * spawning openocd.
111
+ */
112
+ export interface SessionOpenOcd {
113
+ readonly exe: string;
114
+ readonly searchDirs: readonly string[];
115
+ }
116
+ export declare function resolveSessionOpenOcd(env?: {
117
+ OPENOCD?: string | undefined;
118
+ ZEPHYR_SDK_INSTALL_DIR?: string | undefined;
119
+ PATH?: string | undefined;
120
+ }, sdkInstallDir?: string): SessionOpenOcd | undefined;
82
121
  /**
83
122
  * Whether a flash runner carries the upload over a serial port. Runner-gated,
84
123
  * never board-name-gated: esptool and bossac are the only runners
@@ -111,6 +150,16 @@ export declare function buildFlashArgs(buildDir: string, userRunner: string | un
111
150
  * Exported (pure) so the classification is unit-testable without spawning west.
112
151
  */
113
152
  export declare function classifyUploadResult(runner: string | undefined, status: number | null, output: string): boolean;
153
+ /**
154
+ * Whether a `west flash` (openocd) output carries one of the known
155
+ * target-ignored-SWD signatures — the DAP connect failing ("init mode
156
+ * failed (unable to connect to the target)", i.e. the DPIDR read never
157
+ * succeeded) or a reset/halt never landing ("timed out while waiting for
158
+ * target halted" / "TARGET: <name> - Not halted"). Both mean the board (or
159
+ * probe) needs a power-cycle or the SWD-free DFU path, not a retry of the
160
+ * same command. Centralized so the upload hint stays testable.
161
+ */
162
+ export declare function isTargetSwdFailure(output: string): boolean;
114
163
  /**
115
164
  * Cleanse the `west flash` output shown to the user.
116
165
  *
@@ -18,7 +18,7 @@
18
18
  // target is known), GCC errors parsed via the shared parseCompileErrors helper.
19
19
  // ---------------------------------------------------------------------------
20
20
  import { spawnSync } from 'node:child_process';
21
- import { basename, dirname, join } from 'node:path';
21
+ import { basename, delimiter, dirname, join } from 'node:path';
22
22
  import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
23
23
  import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
24
24
  import { scaffoldZephyrProject, writeIfChanged, appendLibraryOverlayFragments } from './scaffold.js';
@@ -322,14 +322,31 @@ export function resolveProbeMethod(zc, chip, purpose = 'flash') {
322
322
  }
323
323
  return { ok: true, runner, args: userArgs };
324
324
  }
325
- /**
326
- * Run an openocd session against the board's probe config — the shared
327
- * engine for the pre-flash quiesce and the post-flash SYSRESETREQ (see the
328
- * call sites in upload()). The config is the probe method's verbatim
329
- * debugCfg from the board catalog (the same lines `west debug` uses);
330
- * `commands` are appended after `-f <cfg> -c init`. Returns a flash note on
331
- * success, undefined when skipped or failed (best-effort by design).
332
- */
325
+ export function resolveSessionOpenOcd(env = process.env, sdkInstallDir) {
326
+ const exeName = process.platform === 'win32' ? 'openocd.exe' : 'openocd';
327
+ if (env.OPENOCD && existsSync(env.OPENOCD)) {
328
+ return { exe: env.OPENOCD, searchDirs: [] };
329
+ }
330
+ const sdkRoot = env.ZEPHYR_SDK_INSTALL_DIR || sdkInstallDir;
331
+ if (sdkRoot) {
332
+ const exe = join(sdkRoot, 'hosttools', 'openocd', 'bin', exeName);
333
+ if (existsSync(exe)) {
334
+ const searchDirs = [
335
+ join(sdkRoot, 'hosttools', 'openocd', 'share', 'openocd', 'scripts'),
336
+ join(sdkRoot, 'hosttools', 'openocd', 'scripts'),
337
+ ].filter((d) => existsSync(d));
338
+ return { exe, searchDirs };
339
+ }
340
+ }
341
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
342
+ if (!dir)
343
+ continue;
344
+ const candidate = join(dir, exeName);
345
+ if (existsSync(candidate))
346
+ return { exe: candidate, searchDirs: [] };
347
+ }
348
+ return undefined;
349
+ }
333
350
  function openocdProbeSession(buildDir, zc, chip, commands) {
334
351
  // Config resolution — two sources, in order:
335
352
  // 1. The named probe method's verbatim debugCfg from the board catalog
@@ -360,17 +377,11 @@ function openocdProbeSession(buildDir, zc, chip, commands) {
360
377
  .map((a) => a.slice('--cmd-pre-init='.length)),
361
378
  ];
362
379
  const install = discoverWest();
363
- const sdkRoot = process.env.ZEPHYR_SDK_INSTALL_DIR || install?.sdkInstallDir;
364
- if (!sdkRoot)
365
- return undefined;
366
- const openocdExe = join(sdkRoot, 'hosttools', 'openocd', 'bin', process.platform === 'win32' ? 'openocd.exe' : 'openocd');
367
- if (!existsSync(openocdExe))
380
+ const sessionOpenOcd = resolveSessionOpenOcd(process.env, install?.sdkInstallDir);
381
+ if (!sessionOpenOcd)
368
382
  return undefined;
369
- // Script search path: the SDK layouts differ across versions — prefer the
370
- // share/ form west's own runner uses, fall back to the scripts/ form.
371
- const shareScripts = join(sdkRoot, 'hosttools', 'openocd', 'share', 'openocd', 'scripts');
372
- const binScripts = join(sdkRoot, 'hosttools', 'openocd', 'scripts');
373
- const searchDir = existsSync(shareScripts) ? shareScripts : binScripts;
383
+ const openocdExe = sessionOpenOcd.exe;
384
+ const searchArgs = sessionOpenOcd.searchDirs.flatMap((d) => ['-s', d]);
374
385
  let cfgArgs;
375
386
  let sessionCfg;
376
387
  if (cfgLines && cfgLines.length > 0) {
@@ -409,7 +420,7 @@ function openocdProbeSession(buildDir, zc, chip, commands) {
409
420
  cfgArgs = ['-f', sessionCfg];
410
421
  }
411
422
  const res = spawnSync(openocdExe, [
412
- '-s', searchDir, ...cfgArgs,
423
+ ...searchArgs, ...cfgArgs,
413
424
  // Pre-init TCL AFTER the cfg (overrides its reset_config) and BEFORE
414
425
  // init — the same position west gives --cmd-pre-init.
415
426
  ...preInit.map((c) => ['-c', c]).flat(),
@@ -503,6 +514,18 @@ function isUf2DriveVanishRace(output) {
503
514
  && /WinError 433/.test(output)
504
515
  && /copymode/.test(output);
505
516
  }
517
+ /**
518
+ * Whether a `west flash` (openocd) output carries one of the known
519
+ * target-ignored-SWD signatures — the DAP connect failing ("init mode
520
+ * failed (unable to connect to the target)", i.e. the DPIDR read never
521
+ * succeeded) or a reset/halt never landing ("timed out while waiting for
522
+ * target halted" / "TARGET: <name> - Not halted"). Both mean the board (or
523
+ * probe) needs a power-cycle or the SWD-free DFU path, not a retry of the
524
+ * same command. Centralized so the upload hint stays testable.
525
+ */
526
+ export function isTargetSwdFailure(output) {
527
+ return /unable to connect to the target|timed out while waiting for target halted|TARGET: \S+ - Not halted/.test(output);
528
+ }
506
529
  /**
507
530
  * Cleanse the `west flash` output shown to the user.
508
531
  *
@@ -1112,6 +1135,12 @@ export const Toolchain = {
1112
1135
  ]);
1113
1136
  if (revived)
1114
1137
  flashNotes.push(revived);
1138
+ // Known SWD-failure signatures get a recovery pointer — a board that
1139
+ // ignores SWD until power-cycled (low-power state, lockup, a wedged
1140
+ // probe) otherwise reads as a toolchain bug.
1141
+ if (isTargetSwdFailure(raw)) {
1142
+ flashNotes.push('-- target ignored SWD — if a retry fails too: power-cycle the board, replug the probe, or skip SWD entirely (hold BOOT0, tap reset, re-run with --probe dfu)');
1143
+ }
1115
1144
  }
1116
1145
  }
1117
1146
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typecad/framework-zephyr",
3
- "version": "1.0.0-alpha.15",
3
+ "version": "1.0.0-alpha.16",
4
4
  "description": "TypeCAD framework package for the Zephyr RTOS \u2014 west/CMake build, devicetree-driven GPIO",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,11 +38,11 @@
38
38
  "dry-run": "node installer/install.mjs --dry-run"
39
39
  },
40
40
  "dependencies": {
41
- "@typecad/cuttlefish": "1.0.0-alpha.15",
42
- "@typecad/hal": "1.0.0-alpha.15"
41
+ "@typecad/cuttlefish": "1.0.0-alpha.16",
42
+ "@typecad/hal": "1.0.0-alpha.16"
43
43
  },
44
44
  "devDependencies": {
45
- "@typecad/expect": "1.0.0-alpha.15",
45
+ "@typecad/expect": "1.0.0-alpha.16",
46
46
  "typescript": "^5.7.3"
47
47
  },
48
48
  "license": "MIT",
@@ -369,14 +369,24 @@ function buildLaunchConfig(
369
369
  // thb (the first user Continue stops at main()) but hands the run/stop
370
370
  // transition to cortex-debug.
371
371
  //
372
- // Paths use forward slashes ${workspaceFolder} on Windows produces
373
- // backslashes that GDB interprets as escape sequences (\t → tab, etc.).
374
- const ws = o.workspaceRoot.replace(/\\/g, '/');
372
+ // Paths in GDB commands must be RELATIVE (resolved against GDB's own
373
+ // working directory), never ${workspaceFolder} literals or absolute paths:
374
+ // - cortex-debug passes command strings through verbatim — no variable
375
+ // expansion in postAttachCommands (verified against its gdb.ts) — so a
376
+ // ${workspaceFolder} would reach GDB as a literal.
377
+ // - Even where VS Code expands it, on Windows the expansion carries
378
+ // backslashes that GDB reads as escape sequences (\t → tab, etc.).
379
+ // - An absolute path bakes the generating machine's checkout location
380
+ // into launch.json and breaks on every other clone/checkout.
381
+ // cortex-debug spawns GDB with cwd from the config's `cwd` field
382
+ // (${workspaceFolder} above), so a relative path IS the workspace root —
383
+ // `set directories .` adds exactly the directory the old absolute form did,
384
+ // and `source <rel>` finds the frame-filter script.
375
385
  // The Xtensa flash-mapping commands are ESP32-specific (app flash isn't
376
386
  // readable until the bootloader maps it); ARM targets drop them.
377
387
  const isEsp32Target = o.target.split('/')[0].startsWith('esp32');
378
388
  const postAttachCommands = [
379
- `set directories ${ws}`,
389
+ 'set directories .',
380
390
  'set remote hardware-watchpoint-limit 2',
381
391
  'set remote hardware-breakpoint-limit 2',
382
392
  ...(isEsp32Target ? [
@@ -388,7 +398,7 @@ function buildLaunchConfig(
388
398
  ...(isEsp32Target ? ['c'] : []),
389
399
  ];
390
400
  if (gdbScriptRel) {
391
- postAttachCommands.splice(1, 0, `source ${ws}/${gdbScriptRel}`);
401
+ postAttachCommands.splice(1, 0, `source ${gdbScriptRel}`);
392
402
  }
393
403
 
394
404
  // Probe-method-driven server shape: jlink-runner methods use cortex-debug's
@@ -19,7 +19,7 @@
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
21
  import { spawnSync } from 'node:child_process';
22
- import { basename, dirname, join } from 'node:path';
22
+ import { basename, delimiter, dirname, join } from 'node:path';
23
23
  import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
24
24
  import type { ToolchainOptions, CompileResult, UploadResult } from '@typecad/cuttlefish/api/shared';
25
25
  import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
@@ -402,6 +402,64 @@ export function resolveProbeMethod(
402
402
  * `commands` are appended after `-f <cfg> -c init`. Returns a flash note on
403
403
  * success, undefined when skipped or failed (best-effort by design).
404
404
  */
405
+ /**
406
+ * Resolve the openocd binary (plus script search dirs) for the probe session.
407
+ *
408
+ * Resolution order mirrors how west's own openocd runner finds the binary, so
409
+ * the session and `west flash` drive the SAME openocd:
410
+ * 1. $OPENOCD — the variable Zephyr's CMake reads into the build cache.
411
+ * 2. A Zephyr-SDK-hosted install ($ZEPHYR_SDK_INSTALL_DIR, else the
412
+ * discovered west install's SDK). Layout differs by SDK generation —
413
+ * hosttools/openocd/share/openocd/scripts vs hosttools/openocd/scripts —
414
+ * so both script dirs are collected.
415
+ * 3. PATH — Linux distro / conda / micromamba installs (a micromamba-env
416
+ * openocd is the common Linux setup; the SDK layout check alone made the
417
+ * deterministic probe session unreachable there, silently dropping every
418
+ * Linux flash to the racy `west flash` fallback).
419
+ *
420
+ * A non-SDK binary resolves with no explicit -s dirs: it finds its own
421
+ * interface/target scripts via its compiled-in search path. Returns undefined
422
+ * when no openocd can be found (the caller then uses the west fallback).
423
+ *
424
+ * Exported (pure) so the resolution contract is unit-testable without
425
+ * spawning openocd.
426
+ */
427
+ export interface SessionOpenOcd {
428
+ readonly exe: string;
429
+ readonly searchDirs: readonly string[];
430
+ }
431
+
432
+ export function resolveSessionOpenOcd(
433
+ env: {
434
+ OPENOCD?: string | undefined;
435
+ ZEPHYR_SDK_INSTALL_DIR?: string | undefined;
436
+ PATH?: string | undefined;
437
+ } = process.env,
438
+ sdkInstallDir?: string,
439
+ ): SessionOpenOcd | undefined {
440
+ const exeName = process.platform === 'win32' ? 'openocd.exe' : 'openocd';
441
+ if (env.OPENOCD && existsSync(env.OPENOCD)) {
442
+ return { exe: env.OPENOCD, searchDirs: [] };
443
+ }
444
+ const sdkRoot = env.ZEPHYR_SDK_INSTALL_DIR || sdkInstallDir;
445
+ if (sdkRoot) {
446
+ const exe = join(sdkRoot, 'hosttools', 'openocd', 'bin', exeName);
447
+ if (existsSync(exe)) {
448
+ const searchDirs = [
449
+ join(sdkRoot, 'hosttools', 'openocd', 'share', 'openocd', 'scripts'),
450
+ join(sdkRoot, 'hosttools', 'openocd', 'scripts'),
451
+ ].filter((d) => existsSync(d));
452
+ return { exe, searchDirs };
453
+ }
454
+ }
455
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
456
+ if (!dir) continue;
457
+ const candidate = join(dir, exeName);
458
+ if (existsSync(candidate)) return { exe: candidate, searchDirs: [] };
459
+ }
460
+ return undefined;
461
+ }
462
+
405
463
  function openocdProbeSession(
406
464
  buildDir: string,
407
465
  zc: Record<string, unknown> | undefined,
@@ -438,16 +496,10 @@ function openocdProbeSession(
438
496
  ];
439
497
 
440
498
  const install = discoverWest();
441
- const sdkRoot = process.env.ZEPHYR_SDK_INSTALL_DIR || install?.sdkInstallDir;
442
- if (!sdkRoot) return undefined;
443
- const openocdExe = join(sdkRoot, 'hosttools', 'openocd', 'bin',
444
- process.platform === 'win32' ? 'openocd.exe' : 'openocd');
445
- if (!existsSync(openocdExe)) return undefined;
446
- // Script search path: the SDK layouts differ across versions — prefer the
447
- // share/ form west's own runner uses, fall back to the scripts/ form.
448
- const shareScripts = join(sdkRoot, 'hosttools', 'openocd', 'share', 'openocd', 'scripts');
449
- const binScripts = join(sdkRoot, 'hosttools', 'openocd', 'scripts');
450
- const searchDir = existsSync(shareScripts) ? shareScripts : binScripts;
499
+ const sessionOpenOcd = resolveSessionOpenOcd(process.env, install?.sdkInstallDir);
500
+ if (!sessionOpenOcd) return undefined;
501
+ const openocdExe = sessionOpenOcd.exe;
502
+ const searchArgs = sessionOpenOcd.searchDirs.flatMap((d) => ['-s', d] as [string, string]);
451
503
 
452
504
  let cfgArgs: string[] | undefined;
453
505
  let sessionCfg: string | undefined;
@@ -481,7 +533,7 @@ function openocdProbeSession(
481
533
  cfgArgs = ['-f', sessionCfg];
482
534
  }
483
535
  const res = spawnSync(openocdExe, [
484
- '-s', searchDir, ...cfgArgs!,
536
+ ...searchArgs, ...cfgArgs!,
485
537
  // Pre-init TCL AFTER the cfg (overrides its reset_config) and BEFORE
486
538
  // init — the same position west gives --cmd-pre-init.
487
539
  ...preInit.map((c) => ['-c', c] as [string, string]).flat(),
@@ -587,6 +639,19 @@ function isUf2DriveVanishRace(output: string): boolean {
587
639
  && /copymode/.test(output);
588
640
  }
589
641
 
642
+ /**
643
+ * Whether a `west flash` (openocd) output carries one of the known
644
+ * target-ignored-SWD signatures — the DAP connect failing ("init mode
645
+ * failed (unable to connect to the target)", i.e. the DPIDR read never
646
+ * succeeded) or a reset/halt never landing ("timed out while waiting for
647
+ * target halted" / "TARGET: <name> - Not halted"). Both mean the board (or
648
+ * probe) needs a power-cycle or the SWD-free DFU path, not a retry of the
649
+ * same command. Centralized so the upload hint stays testable.
650
+ */
651
+ export function isTargetSwdFailure(output: string): boolean {
652
+ return /unable to connect to the target|timed out while waiting for target halted|TARGET: \S+ - Not halted/.test(output);
653
+ }
654
+
590
655
  /**
591
656
  * Cleanse the `west flash` output shown to the user.
592
657
  *
@@ -1216,6 +1281,14 @@ export const Toolchain = {
1216
1281
  'sleep 300',
1217
1282
  ]);
1218
1283
  if (revived) flashNotes.push(revived);
1284
+ // Known SWD-failure signatures get a recovery pointer — a board that
1285
+ // ignores SWD until power-cycled (low-power state, lockup, a wedged
1286
+ // probe) otherwise reads as a toolchain bug.
1287
+ if (isTargetSwdFailure(raw)) {
1288
+ flashNotes.push(
1289
+ '-- target ignored SWD — if a retry fails too: power-cycle the board, replug the probe, or skip SWD entirely (hold BOOT0, tap reset, re-run with --probe dfu)',
1290
+ );
1291
+ }
1219
1292
  }
1220
1293
  } else {
1221
1294
  ok = true;