@typecad/framework-zephyr 1.0.0-alpha.12 → 1.0.0-alpha.14

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.
Files changed (67) hide show
  1. package/README.md +13 -1
  2. package/dist/chips/controllers.d.ts +28 -8
  3. package/dist/chips/controllers.js +49 -12
  4. package/dist/chips/resolve.js +32 -6
  5. package/dist/chips/types.d.ts +63 -12
  6. package/dist/chips/xiao-ble.js +12 -0
  7. package/dist/display/index.d.ts +1 -1
  8. package/dist/display/index.js +1 -1
  9. package/dist/display/profiles.d.ts +8 -0
  10. package/dist/display/profiles.js +19 -0
  11. package/dist/display/ui-adapter.d.ts +4 -0
  12. package/dist/display/ui-adapter.js +46 -0
  13. package/dist/dt-config/kconfig.d.ts +11 -0
  14. package/dist/dt-config/kconfig.js +1 -1
  15. package/dist/dt-config/overlay.d.ts +8 -1
  16. package/dist/dt-config/overlay.js +104 -1
  17. package/dist/framework.manifest.d.ts +20 -30
  18. package/dist/framework.manifest.js +12 -3
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js +5 -0
  21. package/dist/lowering/adc.d.ts +7 -4
  22. package/dist/lowering/adc.js +25 -11
  23. package/dist/lowering/gpio.js +15 -8
  24. package/dist/lowering/mqtt.js +9 -1
  25. package/dist/lowering/pulse.js +7 -7
  26. package/dist/lowering/pwm.d.ts +21 -3
  27. package/dist/lowering/pwm.js +28 -4
  28. package/dist/lowering/spi.js +2 -2
  29. package/dist/lowering/tone.js +3 -2
  30. package/dist/lowering/wifi.js +28 -5
  31. package/dist/strategy.d.ts +20 -0
  32. package/dist/strategy.js +295 -119
  33. package/dist/toolchain/debug-config.d.ts +43 -2
  34. package/dist/toolchain/debug-config.js +129 -17
  35. package/dist/toolchain/index.d.ts +14 -1
  36. package/dist/toolchain/index.js +146 -20
  37. package/dist/toolchain/scaffold.d.ts +9 -0
  38. package/dist/toolchain/scaffold.js +84 -19
  39. package/dist/toolchain/west-discover.d.ts +4 -1
  40. package/dist/toolchain/west-discover.js +2 -0
  41. package/dist/toolchain/west-spawn.js +17 -5
  42. package/package.json +5 -5
  43. package/src/chips/controllers.ts +61 -12
  44. package/src/chips/resolve.ts +32 -5
  45. package/src/chips/types.ts +63 -12
  46. package/src/chips/xiao-ble.ts +82 -70
  47. package/src/display/index.ts +1 -1
  48. package/src/display/profiles.ts +23 -0
  49. package/src/display/ui-adapter.ts +51 -0
  50. package/src/dt-config/kconfig.ts +12 -1
  51. package/src/dt-config/overlay.ts +123 -0
  52. package/src/framework.manifest.ts +12 -3
  53. package/src/index.ts +6 -0
  54. package/src/lowering/adc.ts +28 -12
  55. package/src/lowering/gpio.ts +15 -8
  56. package/src/lowering/mqtt.ts +9 -1
  57. package/src/lowering/pulse.ts +7 -7
  58. package/src/lowering/pwm.ts +29 -4
  59. package/src/lowering/spi.ts +2 -2
  60. package/src/lowering/tone.ts +3 -3
  61. package/src/lowering/wifi.ts +29 -5
  62. package/src/strategy.ts +320 -123
  63. package/src/toolchain/debug-config.ts +137 -14
  64. package/src/toolchain/index.ts +645 -513
  65. package/src/toolchain/scaffold.ts +81 -17
  66. package/src/toolchain/west-discover.ts +321 -316
  67. package/src/toolchain/west-spawn.ts +17 -5
@@ -13,8 +13,10 @@ export interface DebugConfigOptions {
13
13
  sourceMapPath?: string;
14
14
  }
15
15
  /**
16
- * Resolve the GDB binary path for the target from the build cache. Zephyr
17
- * records ZEPHYR_SDK_INSTALL_DIR in CMakeCache.txt at configure time, and the
16
+ * Resolve the GDB binary path for the target from the build cache, falling
17
+ * back to a filesystem scan of known Zephyr SDK locations when no build
18
+ * exists yet (the create-time starter artifacts path). Zephyr records
19
+ * ZEPHYR_SDK_INSTALL_DIR in CMakeCache.txt at configure time, and the
18
20
  * xtensa GDB lives at <sdk>/xtensa-espressif_esp32s3_zephyr-elf/bin/... (note
19
21
  * the Zephyr-SDK naming, distinct from the ESP-IDF xtensa-esp32s3-elf-gdb).
20
22
  *
@@ -22,6 +24,24 @@ export interface DebugConfigOptions {
22
24
  * omits gdbPath and relies on Cortex-Debug's default resolution).
23
25
  */
24
26
  export declare function resolveGdbPath(buildDir: string, target: string): string | undefined;
27
+ /** The esp32s3 xtensa GDB location inside a Zephyr SDK root (verified against
28
+ * zephyr-sdk-0.17.4). Returns a forward-slash absolute path or undefined. */
29
+ export declare function gdbPathFromSdkRoot(sdkRoot: string): string | undefined;
30
+ /**
31
+ * Probe the well-known Zephyr SDK install locations, newest version first:
32
+ * 1. $ZEPHYR_SDK_INSTALL_DIR (the var board.cmake reads)
33
+ * 2. <MAMBA_ROOT_PREFIX | ~/micromamba>/zephyr-sdk/zephyr-sdk-<ver> — the
34
+ * @typecad/zephyr-installer layout
35
+ * 3. ~/zephyr-sdk-<ver> — the standalone download layout
36
+ *
37
+ * Only roots that actually contain the esp32s3 GDB are useful to callers;
38
+ * this returns candidate roots (gdbPathFromSdkRoot does the existence check)
39
+ * so tests can inject home/env overrides.
40
+ */
41
+ export declare function discoverZephyrSdkRoots(opts?: {
42
+ home?: string;
43
+ env?: Record<string, string | undefined>;
44
+ }): string[];
25
45
  /**
26
46
  * Resolve the Espressif OpenOCD binary path. The esp32s3 needs the Espressif
27
47
  * OpenOCD fork (openocd-esp32) — not the Zephyr SDK's openocd and not a
@@ -80,3 +100,24 @@ export declare function generateGdbScript(sourceMapPath?: string): string | null
80
100
  * <projectRoot>/.cuttlefish/.cuttlefish-gdb.py (lambda frame filter, conditional)
81
101
  */
82
102
  export declare function writeDebugConfig(o: DebugConfigOptions): void;
103
+ /**
104
+ * Create-time starter debug artifacts. Called by the cuttlefish `create` flow
105
+ * (via the package's `writeProjectDebugArtifacts` export) so a fresh project
106
+ * has a working F5 before any build exists:
107
+ *
108
+ * The launch.json's preLaunchTask runs `cuttlefish build --compile --upload
109
+ * --debug`, which builds + flashes AND rewrites this same launch entry (merged
110
+ * by name) with the CMakeCache-resolved gdbPath — so the starter files upgrade
111
+ * themselves on the first debug build.
112
+ *
113
+ * No-ops (returns []) for targets without native GDB support (debugMode() !==
114
+ * 'gdb'); the gdb frame-filter script is skipped (no source map exists yet).
115
+ *
116
+ * Returns the workspace-relative paths written, for CLI reporting.
117
+ */
118
+ export declare function writeProjectDebugArtifacts(o: {
119
+ /** Absolute path to the cuttlefish project root (contains cuttlefish.config.ts). */
120
+ workspaceRoot: string;
121
+ /** The Zephyr board id from the project config (frameworkData.buildTarget). */
122
+ buildTarget?: string;
123
+ }): string[];
@@ -16,9 +16,12 @@
16
16
  // ---------------------------------------------------------------------------
17
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
18
18
  import { join, resolve, dirname, relative } from 'node:path';
19
+ import { ZephyrStrategy } from '../strategy.js';
19
20
  /**
20
- * Resolve the GDB binary path for the target from the build cache. Zephyr
21
- * records ZEPHYR_SDK_INSTALL_DIR in CMakeCache.txt at configure time, and the
21
+ * Resolve the GDB binary path for the target from the build cache, falling
22
+ * back to a filesystem scan of known Zephyr SDK locations when no build
23
+ * exists yet (the create-time starter artifacts path). Zephyr records
24
+ * ZEPHYR_SDK_INSTALL_DIR in CMakeCache.txt at configure time, and the
22
25
  * xtensa GDB lives at <sdk>/xtensa-espressif_esp32s3_zephyr-elf/bin/... (note
23
26
  * the Zephyr-SDK naming, distinct from the ESP-IDF xtensa-esp32s3-elf-gdb).
24
27
  *
@@ -26,27 +29,92 @@ import { join, resolve, dirname, relative } from 'node:path';
26
29
  * omits gdbPath and relies on Cortex-Debug's default resolution).
27
30
  */
28
31
  export function resolveGdbPath(buildDir, target) {
32
+ void target; // toolchain dir is esp32s3-specific today; see gdbPathFromSdkRoot
29
33
  const cachePath = join(buildDir, 'CMakeCache.txt');
30
- if (!existsSync(cachePath))
31
- return undefined;
32
- let sdk = '';
33
- try {
34
- const cache = readFileSync(cachePath, 'utf-8');
35
- const m = cache.match(/^ZEPHYR_SDK_INSTALL_DIR:PATH=(.+)$/m);
36
- if (m)
37
- sdk = m[1].trim();
34
+ if (existsSync(cachePath)) {
35
+ try {
36
+ const cache = readFileSync(cachePath, 'utf-8');
37
+ const m = cache.match(/^ZEPHYR_SDK_INSTALL_DIR:PATH=(.+)$/m);
38
+ if (m) {
39
+ const fromCache = gdbPathFromSdkRoot(m[1].trim());
40
+ if (fromCache)
41
+ return fromCache;
42
+ }
43
+ }
44
+ catch {
45
+ // unreadable cache — fall through to the SDK scan
46
+ }
38
47
  }
39
- catch {
40
- return undefined;
48
+ // No build dir yet (project just created): probe known SDK locations.
49
+ for (const sdkRoot of discoverZephyrSdkRoots()) {
50
+ const p = gdbPathFromSdkRoot(sdkRoot);
51
+ if (p)
52
+ return p;
41
53
  }
42
- if (!sdk)
43
- return undefined;
44
- // The Zephyr SDK toolchain dir is target-specific. For esp32s3 it is
45
- // xtensa-espressif_esp32s3_zephyr-elf (verified against zephyr-sdk-0.17.4).
54
+ return undefined;
55
+ }
56
+ /** The esp32s3 xtensa GDB location inside a Zephyr SDK root (verified against
57
+ * zephyr-sdk-0.17.4). Returns a forward-slash absolute path or undefined. */
58
+ export function gdbPathFromSdkRoot(sdkRoot) {
46
59
  const gdbName = 'xtensa-espressif_esp32s3_zephyr-elf-gdb.exe';
47
- const gdbPath = join(sdk, 'xtensa-espressif_esp32s3_zephyr-elf', 'bin', gdbName);
60
+ const gdbPath = join(sdkRoot, 'xtensa-espressif_esp32s3_zephyr-elf', 'bin', gdbName);
48
61
  return existsSync(gdbPath) ? gdbPath.replace(/\\/g, '/') : undefined;
49
62
  }
63
+ /** Compare two dotted version strings numerically (0.17.10 > 0.17.4). */
64
+ function compareSdkVersions(a, b) {
65
+ const segsOf = (v) => v.split('.').map((s) => parseInt(s, 10) || 0);
66
+ const aa = segsOf(a);
67
+ const bb = segsOf(b);
68
+ for (let i = 0; i < Math.max(aa.length, bb.length); i++) {
69
+ const d = (aa[i] ?? 0) - (bb[i] ?? 0);
70
+ if (d !== 0)
71
+ return d;
72
+ }
73
+ return 0;
74
+ }
75
+ /**
76
+ * Probe the well-known Zephyr SDK install locations, newest version first:
77
+ * 1. $ZEPHYR_SDK_INSTALL_DIR (the var board.cmake reads)
78
+ * 2. <MAMBA_ROOT_PREFIX | ~/micromamba>/zephyr-sdk/zephyr-sdk-<ver> — the
79
+ * @typecad/zephyr-installer layout
80
+ * 3. ~/zephyr-sdk-<ver> — the standalone download layout
81
+ *
82
+ * Only roots that actually contain the esp32s3 GDB are useful to callers;
83
+ * this returns candidate roots (gdbPathFromSdkRoot does the existence check)
84
+ * so tests can inject home/env overrides.
85
+ */
86
+ export function discoverZephyrSdkRoots(opts) {
87
+ const env = opts?.env ?? process.env;
88
+ const home = opts?.home ?? (env.USERPROFILE || env.HOME || '');
89
+ const scanned = [];
90
+ const versionedDirs = (base) => {
91
+ try {
92
+ return readdirSync(base)
93
+ .filter((d) => existsSync(join(base, d)) && d.startsWith('zephyr-sdk-'))
94
+ .map((d) => join(base, d));
95
+ }
96
+ catch {
97
+ return []; // dir absent
98
+ }
99
+ };
100
+ const mambaRoot = env.MAMBA_ROOT_PREFIX || (home ? join(home, 'micromamba') : '');
101
+ if (mambaRoot)
102
+ scanned.push(...versionedDirs(join(mambaRoot, 'zephyr-sdk')));
103
+ if (home)
104
+ scanned.push(...versionedDirs(home));
105
+ // Scanned roots newest version first; the env var stays pinned first
106
+ // (explicit user intent outranks any discovered location).
107
+ scanned.sort((a, b) => {
108
+ const va = a.match(/zephyr-sdk-([\d.]+)/)?.[1] ?? '';
109
+ const vb = b.match(/zephyr-sdk-([\d.]+)/)?.[1] ?? '';
110
+ return compareSdkVersions(vb, va);
111
+ });
112
+ const roots = env.ZEPHYR_SDK_INSTALL_DIR
113
+ ? [env.ZEPHYR_SDK_INSTALL_DIR, ...scanned]
114
+ : scanned;
115
+ // De-duplicate (an env var may repeat a scan hit) preserving order.
116
+ return roots.filter((r, i) => roots.indexOf(r) === i);
117
+ }
50
118
  /**
51
119
  * Resolve the Espressif OpenOCD binary path. The esp32s3 needs the Espressif
52
120
  * OpenOCD fork (openocd-esp32) — not the Zephyr SDK's openocd and not a
@@ -357,3 +425,47 @@ export function writeDebugConfig(o) {
357
425
  const task = buildTask(o);
358
426
  mergeJsonArrayEntry(join(vscodeDir, 'tasks.json'), 'tasks', 'label', task);
359
427
  }
428
+ /**
429
+ * The Zephyr app dir a `cuttlefish create` scaffold produces, relative to the
430
+ * project root: the scaffold fixes entry `./src/main.ts` + outDir `./out`, and
431
+ * the CLI resolves output.outDir against the ENTRY's directory (cli.ts), so
432
+ * the emitted app root — and therefore the ELF, build dir, and .cuttlefish/
433
+ * debug artifacts — always lands at `src/out`. Keep in sync with
434
+ * generateProjectConfig in @typecad/cuttlefish create/templates.ts.
435
+ */
436
+ const STARTER_SKETCH_REL = 'src/out';
437
+ /**
438
+ * Create-time starter debug artifacts. Called by the cuttlefish `create` flow
439
+ * (via the package's `writeProjectDebugArtifacts` export) so a fresh project
440
+ * has a working F5 before any build exists:
441
+ *
442
+ * The launch.json's preLaunchTask runs `cuttlefish build --compile --upload
443
+ * --debug`, which builds + flashes AND rewrites this same launch entry (merged
444
+ * by name) with the CMakeCache-resolved gdbPath — so the starter files upgrade
445
+ * themselves on the first debug build.
446
+ *
447
+ * No-ops (returns []) for targets without native GDB support (debugMode() !==
448
+ * 'gdb'); the gdb frame-filter script is skipped (no source map exists yet).
449
+ *
450
+ * Returns the workspace-relative paths written, for CLI reporting.
451
+ */
452
+ export function writeProjectDebugArtifacts(o) {
453
+ if (new ZephyrStrategy().debugMode(o.buildTarget) !== 'gdb')
454
+ return [];
455
+ const workspaceRoot = resolve(o.workspaceRoot);
456
+ const projectRoot = join(workspaceRoot, STARTER_SKETCH_REL);
457
+ writeDebugConfig({
458
+ projectRoot,
459
+ workspaceRoot,
460
+ sketchRel: STARTER_SKETCH_REL,
461
+ target: o.buildTarget ?? '',
462
+ // No build dir exists yet — resolveGdbPath falls back to probing known
463
+ // Zephyr SDK locations so gdbPath is still filled in when possible.
464
+ buildDir: join(projectRoot, 'build'),
465
+ });
466
+ return [
467
+ '.vscode/launch.json',
468
+ '.vscode/tasks.json',
469
+ `${STARTER_SKETCH_REL}/.cuttlefish/openocd.cfg`,
470
+ ];
471
+ }
@@ -26,7 +26,7 @@ export declare function projectRootFromOptions(o: ToolchainOptions): string;
26
26
  * Exported (pure) so the runner-selection contract is unit-testable without
27
27
  * spawning west.
28
28
  */
29
- export declare function buildFlashArgs(buildDir: string, board: string, userRunner: string | undefined, port: string | undefined): string[];
29
+ export declare function buildFlashArgs(buildDir: string, board: string, userRunner: string | undefined, port: string | undefined, runnerArgs?: readonly string[]): string[];
30
30
  /**
31
31
  * Classify a `west flash` result as success/failure.
32
32
  *
@@ -59,6 +59,19 @@ export declare function classifyUploadResult(runner: string | undefined, status:
59
59
  * Exported (pure) so the cleansing is unit-testable without spawning west.
60
60
  */
61
61
  export declare function cleanseUploadOutput(runner: string | undefined, status: number | null, output: string): string;
62
+ /**
63
+ * Whether a failed `west build` output carries ninja's `dependency cycle`
64
+ * signature. Zephyr 4.3.99-dev snapshots have a regression
65
+ * (zephyrproject-rtos/zephyr#104757, fixed upstream by the #104784 revert,
66
+ * in v4.4+): after CMake re-runs from a .config change, the build dir's
67
+ * .ninja_deps records an `offsets.h -> offsets.c.obj -> offsets.h` cycle and
68
+ * ninja aborts with `ninja: error: dependency cycle: ...` before compiling
69
+ * anything. The cycle lives in the build dir, not the sources, so compile()
70
+ * recovers by deleting the dir and retrying once.
71
+ *
72
+ * Exported (pure) so the detection is unit-testable without spawning west.
73
+ */
74
+ export declare function isDependencyCycleFailure(output: string): boolean;
62
75
  /**
63
76
  * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
64
77
  * The target board is carried via frameworkData.buildTarget; scaffolding
@@ -21,17 +21,77 @@ import { spawnSync } from 'node:child_process';
21
21
  import { basename, dirname, join } from 'node:path';
22
22
  import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
23
23
  import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
24
- import { scaffoldZephyrProject, writeIfChanged } from './scaffold.js';
24
+ import { scaffoldZephyrProject, writeIfChanged, appendLibraryOverlayFragments } from './scaffold.js';
25
25
  import { westSpawn, buildEnv } from './west-spawn.js';
26
26
  import { discoverWest } from './west-discover.js';
27
27
  import { writeDebugConfig, resolveDebugLocations } from './debug-config.js';
28
28
  import { ZephyrStrategy } from '../strategy.js';
29
29
  import { generateOverlay } from '../dt-config/overlay.js';
30
30
  import { chipForTarget } from '../chips/index.js';
31
+ import { resolveChipFromBoard } from '../chips/resolve.js';
32
+ import { pwmDtAliasToken } from '../lowering/pwm.js';
31
33
  import { detectZephyrVersion, checkZephyrCompat, resolveBoardTarget } from './compat.js';
32
34
  import { DEFAULT_ZEPHYR_DISPLAY_PROFILE } from '../display/profiles.js';
33
35
  /** Default board target — the framework's MVP canonical board. */
34
36
  const DEFAULT_BOARD = 'xiao_ble';
37
+ /**
38
+ * Resolve the chip for a build the same way the strategy does at emit time —
39
+ * from the board constants the transpile persisted next to the emitted
40
+ * source (`board-constants.json`), falling back to the hardcoded registry.
41
+ * Board-package chips (rpi_pico, esp32c3/c6, blackpill) exist only in their
42
+ * board packages; the registry fallback would silently resolve them to the
43
+ * XIAO default and the overlay generator would emit wrong controller labels
44
+ * (e.g. `&uart0` on an STM32, whose node is `usart1`).
45
+ */
46
+ function chipForBuild(projectRoot, board) {
47
+ try {
48
+ // The transpile writes the constants into the emit outDir, which is
49
+ // <projectRoot>/src for the standard layout (basename 'src' collapsed by
50
+ // projectRootFromOptions); check both locations.
51
+ const bcPath = [join(projectRoot, 'src', 'board-constants.json'), join(projectRoot, 'board-constants.json')]
52
+ .find(p => existsSync(p));
53
+ if (bcPath) {
54
+ const raw = JSON.parse(readFileSync(bcPath, 'utf8'));
55
+ const fromBoard = resolveChipFromBoard(new Map(Object.entries(raw)));
56
+ if (fromBoard)
57
+ return fromBoard;
58
+ }
59
+ }
60
+ catch { /* fall back to the registry below */ }
61
+ return chipForTarget(board);
62
+ }
63
+ /**
64
+ * HAL pins the emitted sources read via adc.* — scanned from the emitted
65
+ * `__tc_adc<N>_setup()` call sites (N = channel index, mapped back to the HAL
66
+ * pin via the chip descriptor). Feeds the overlay's ADC pinctrl rewrite: on
67
+ * SoCs that mux ADC pads via pinctrl (STM32), only the read channels are
68
+ * switched to analog mode.
69
+ */
70
+ function scanAdcReadPins(src, chip) {
71
+ const pins = [];
72
+ // Match CALL SITES only (`__tc_adc<N>_setup()` with empty parens) — the
73
+ // setup definitions emitted by adcInitLines have a `(void)` parameter list
74
+ // and would otherwise mark every descriptor channel as used.
75
+ for (const m of src.matchAll(/__tc_adc(\d+)_setup\(\)/g)) {
76
+ const ch = Number(m[1]);
77
+ const c = chip.adc?.channels.find((x) => x.channel === ch);
78
+ if (c && !pins.includes(c.pin))
79
+ pins.push(c.pin);
80
+ }
81
+ return pins;
82
+ }
83
+ /**
84
+ * HAL pins the emitted sources drive with pwm.* — the emitted source
85
+ * references each used spec as `__tc_pwm_<alias token>` (pwmVarName in
86
+ * lowering/pwm.ts), and the lowering only emits specs for driven pins, so
87
+ * var-presence is the authoritative signal. Feeds the overlay's per-pin
88
+ * pwm-leds gating (no dead DT channels).
89
+ */
90
+ function scanPwmUsedPins(src, chip) {
91
+ return (chip.pwm?.specs ?? [])
92
+ .filter((s) => src.includes(`__tc_pwm_${pwmDtAliasToken(s)}`))
93
+ .map((s) => s.pin);
94
+ }
35
95
  function targetFromOptions(o) {
36
96
  // The cuttlefish CLI populates ToolchainOptions.buildTarget from
37
97
  // config.frameworkData.buildTarget. Accept frameworkData.target as an alias.
@@ -77,7 +137,7 @@ const FLASH_TIMEOUT_MS = 120_000;
77
137
  * Exported (pure) so the runner-selection contract is unit-testable without
78
138
  * spawning west.
79
139
  */
80
- export function buildFlashArgs(buildDir, board, userRunner, port) {
140
+ export function buildFlashArgs(buildDir, board, userRunner, port, runnerArgs) {
81
141
  const args = ['flash', '-d', buildDir];
82
142
  if (userRunner) {
83
143
  args.push('--runner', userRunner);
@@ -85,6 +145,11 @@ export function buildFlashArgs(buildDir, board, userRunner, port) {
85
145
  if (port && board.startsWith('esp32')) {
86
146
  args.push('--esp-device', port);
87
147
  }
148
+ // Extra runner-specific flags, appended verbatim (west's runner parsers
149
+ // accept them after the runner is selected).
150
+ if (runnerArgs && runnerArgs.length > 0) {
151
+ args.push(...runnerArgs);
152
+ }
88
153
  return args;
89
154
  }
90
155
  /**
@@ -145,6 +210,29 @@ export function cleanseUploadOutput(runner, status, output) {
145
210
  }
146
211
  return output;
147
212
  }
213
+ /**
214
+ * Whether a failed `west build` output carries ninja's `dependency cycle`
215
+ * signature. Zephyr 4.3.99-dev snapshots have a regression
216
+ * (zephyrproject-rtos/zephyr#104757, fixed upstream by the #104784 revert,
217
+ * in v4.4+): after CMake re-runs from a .config change, the build dir's
218
+ * .ninja_deps records an `offsets.h -> offsets.c.obj -> offsets.h` cycle and
219
+ * ninja aborts with `ninja: error: dependency cycle: ...` before compiling
220
+ * anything. The cycle lives in the build dir, not the sources, so compile()
221
+ * recovers by deleting the dir and retrying once.
222
+ *
223
+ * Exported (pure) so the detection is unit-testable without spawning west.
224
+ */
225
+ export function isDependencyCycleFailure(output) {
226
+ return output.includes('dependency cycle');
227
+ }
228
+ /** stdout+stderr of a spawnSync result coerced to one string. Defensive about
229
+ * the buffer form (spawnSync only returns strings when `encoding` is set,
230
+ * which every call site here does — but the coercion costs nothing). */
231
+ function combinedSpawnOutput(result) {
232
+ const so = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
233
+ const se = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
234
+ return so + se;
235
+ }
148
236
  /**
149
237
  * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
150
238
  * The target board is carried via frameworkData.buildTarget; scaffolding
@@ -159,7 +247,7 @@ export const Toolchain = {
159
247
  // resolution is a pre-build artifact step.
160
248
  const projectRoot = basename(outputDir) === 'src' ? dirname(outputDir) : outputDir;
161
249
  const board = DEFAULT_BOARD;
162
- const chip = chipForTarget(board);
250
+ const chip = chipForBuild(projectRoot, board);
163
251
  // Scan the emitted source for usage tokens (same authoritative signal the
164
252
  // scaffold uses). entryPoint is the path to main.cpp; its dir is src/.
165
253
  const srcDir = dirname(entryPoint);
@@ -193,6 +281,10 @@ export const Toolchain = {
193
281
  usesI2c: uses('i2c_'),
194
282
  usesSpi: uses('spi_'),
195
283
  usesUart: uses('uart_'),
284
+ usesPwm: uses('pwm_'),
285
+ usesAdc: uses('adc_'),
286
+ adcReadPins: scanAdcReadPins(src, chip),
287
+ pwmUsedPins: scanPwmUsedPins(src, chip),
196
288
  usesDisplay,
197
289
  usesTouch: usesTouch || usesXpt,
198
290
  touchController: usesXpt ? 'xpt2046' : 'ft6336u',
@@ -230,7 +322,7 @@ export const Toolchain = {
230
322
  // the <default>.overlay it wrote does not match `west build -b <board>`.
231
323
  // Zephyr auto-detects boards/<board>.overlay under APPLICATION_CONFIG_DIR.
232
324
  try {
233
- const chip = chipForTarget(board);
325
+ const chip = chipForBuild(projectRoot, board);
234
326
  const srcDir = join(projectRoot, 'src');
235
327
  let src = '';
236
328
  try {
@@ -277,6 +369,7 @@ export const Toolchain = {
277
369
  mosi: typeof spiPins?.mosi === 'number' ? spiPins.mosi : undefined,
278
370
  miso: typeof spiPins?.miso === 'number' ? spiPins.miso : undefined,
279
371
  backlightPin: typeof dispCfg.backlightPin === 'number' ? dispCfg.backlightPin : undefined,
372
+ tearingEffectPin: typeof dispCfg.tearingEffectPin === 'number' ? dispCfg.tearingEffectPin : undefined,
280
373
  }
281
374
  : undefined;
282
375
  // Extract touch pin wiring from the config display.touch section so the
@@ -314,38 +407,54 @@ export const Toolchain = {
314
407
  if (usesXpt) {
315
408
  touchWiring = { controller: 'xpt2046', ...(touchWiring ?? {}) };
316
409
  }
410
+ const overlayDiagnostics = [];
317
411
  const overlay = generateOverlay(chip, {
318
412
  usesI2c: uses('i2c_'),
319
413
  usesSpi: uses('spi_'),
320
414
  usesUart: uses('uart_'),
415
+ usesPwm: uses('pwm_'),
416
+ usesAdc: uses('adc_'),
417
+ adcReadPins: scanAdcReadPins(src, chip),
418
+ pwmUsedPins: scanPwmUsedPins(src, chip),
321
419
  usesDisplay,
322
420
  usesTouch: uses('ft6336u') || uses('touch_') || usesXpt,
323
421
  touchController: usesXpt ? 'xpt2046' : 'ft6336u',
324
422
  psram: o.psram,
325
- }, displayProfile, wiring, touchWiring);
423
+ }, displayProfile, wiring, touchWiring, overlayDiagnostics);
424
+ for (const d of overlayDiagnostics) {
425
+ console.warn(`overlay: ${d.message}`);
426
+ }
326
427
  const overlayDir = join(projectRoot, 'boards');
327
428
  mkdirSync(overlayDir, { recursive: true });
328
429
  // Write the board-specific overlay (the one west loads). Zephyr looks for
329
430
  // boards/<board_id>.overlay under APPLICATION_CONFIG_DIR — use the bare
330
431
  // board id (before any hardware-qualifier suffix, e.g. 'esp32_devkitc'
331
- // not the full 'esp32_devkitc/esp32/procpu' target string).
432
+ // not the full 'esp32_devkitc/esp32/procpu' target string). Library
433
+ // packages' overlay fragments are appended by the scaffold helper.
332
434
  const boardId = board.split('/')[0];
333
- writeIfChanged(join(overlayDir, `${boardId}.overlay`), overlay);
435
+ writeIfChanged(join(overlayDir, `${boardId}.overlay`), appendLibraryOverlayFragments(overlay, projectRoot));
334
436
  }
335
437
  catch { /* best-effort overlay regen; the build surfaces DT errors */ }
336
438
  // Use a stable build dir so incremental builds reuse the Ninja graph.
337
439
  // west defaults to <projectRoot>/build.
338
440
  const buildDir = join(projectRoot, 'build');
339
- // Nuke the build dir whenever a previous build exists. Zephyr's gen_offset
340
- // flow (offsets.h is generated FROM offsets.c.obj, while gen_offset.h makes
341
- // offsets.c include offsets.h) leaves a permanent `offsets.h ->
342
- // offsets.c.obj -> offsets.h` cycle in the .ninja_deps log after the first
343
- // incremental pass ninja then fails every later build with `dependency
344
- // cycle` even when nothing changed. This is a known Zephyr-on-Windows
345
- // issue; the reliable fix is a pristine build dir per build. Also nukes
346
- // when prj.conf/CMakeLists/overlay changed, so Kconfig symbols and
347
- // generated headers never diverge from a cached graph.
348
- if (configChanged || existsSync(join(buildDir, 'zephyr', 'zephyr.bin'))) {
441
+ // Reuse the build dir across builds so ninja recompiles only the changed
442
+ // app translation units and re-links a pristine configure + the
443
+ // ~280-target Zephyr library rebuild costs minutes on Windows
444
+ // (demo-shadcn measures 69s of ninja wall time, 448s of summed compile
445
+ // work, and every build redid all of it). Nuke it only when the generated
446
+ // config changed (prj.conf / CMakeLists content), the one path that must
447
+ // not reuse a cached graph: Zephyr 4.3.99-dev snapshots carry a
448
+ // regression (zephyrproject-rtos/zephyr#104757, fixed by the #104784
449
+ // revert on 2026-03-03, in v4.4+) where re-running CMake after a .config
450
+ // change records an `offsets.h -> offsets.c.obj -> offsets.h` cycle in
451
+ // .ninja_deps, after which every ninja run fails with `dependency cycle`.
452
+ // Plain source edits never reconfigure CMake, so they cannot trigger it —
453
+ // and the retry after the spawn below self-heals any path that still does.
454
+ // Board switches need no nuke here: `west build` is --pristine=auto by
455
+ // default and recreates the dir itself when -b <board> mismatches the
456
+ // cached board.
457
+ if (configChanged) {
349
458
  try {
350
459
  rmSync(buildDir, { recursive: true, force: true });
351
460
  }
@@ -374,10 +483,26 @@ export const Toolchain = {
374
483
  buildArgs.push(...userCmakeArgs);
375
484
  }
376
485
  const inv = westSpawn(buildArgs, { cwd: projectRoot, encoding: 'utf-8', timeout: BUILD_TIMEOUT_MS });
377
- const result = spawnSync(inv.command, inv.args, inv.options);
486
+ let result = spawnSync(inv.command, inv.args, inv.options);
487
+ // Self-heal the Zephyr 4.3.99 dep-cycle regression (see the nuke comment
488
+ // above): when the cached .ninja_deps carries the cycle, ninja aborts with
489
+ // `dependency cycle` before compiling anything. The cycle lives in the
490
+ // build dir, not the sources — one pristine retry clears it and the build
491
+ // proceeds. On fixed Zephyr (>=4.4) this never fires.
492
+ let pristineRetry = false;
493
+ if (result.status !== 0 && isDependencyCycleFailure(combinedSpawnOutput(result))) {
494
+ try {
495
+ rmSync(buildDir, { recursive: true, force: true });
496
+ }
497
+ catch { /* may not exist */ }
498
+ result = spawnSync(inv.command, inv.args, inv.options);
499
+ pristineRetry = true;
500
+ }
378
501
  const stdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
379
502
  const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
380
- const output = stdout + stderr;
503
+ const output = stdout + stderr + (pristineRetry
504
+ ? '\n[cuttlefish] dependency cycle detected in the cached build dir — retried with a pristine build'
505
+ : '');
381
506
  // Prefix the build log with how west was resolved, for transparency.
382
507
  const header = `Using west via ${inv.install.source}` +
383
508
  (inv.install.zephyrBase ? ` (ZEPHYR_BASE=${inv.install.zephyrBase})` : '') + '\n';
@@ -414,7 +539,8 @@ export const Toolchain = {
414
539
  const board = targetFromOptions(o);
415
540
  const zc = o.zephyrConfig;
416
541
  const runner = zc?.runner;
417
- const args = buildFlashArgs(buildDir, board, runner, o.port);
542
+ const runnerArgs = zc?.runnerArgs;
543
+ const args = buildFlashArgs(buildDir, board, runner, o.port, runnerArgs);
418
544
  const inv = westSpawn(args, {
419
545
  cwd: projectRoot,
420
546
  encoding: 'utf-8',
@@ -1,6 +1,15 @@
1
1
  /** Write a file only if the content differs from the existing file.
2
2
  * Returns true when the file was written (content changed or file was new). */
3
3
  export declare function writeIfChanged(filePath: string, content: string): boolean;
4
+ /**
5
+ * Append cuttlefish library packages' devicetree overlay fragments to the
6
+ * generated overlay. Library entries come from the transpiler's libraries.json
7
+ * sidecar (next to the emitted sources) and are already gated on the
8
+ * library's include token appearing in the emitted sources — no re-detection.
9
+ * Fragments merge after the framework overlay so library nodes (e.g.
10
+ * @typecad/zephyr-esp32s3-rgb's WS2812 node on I2S0) layer over it.
11
+ */
12
+ export declare function appendLibraryOverlayFragments(overlay: string, projectRoot: string): string;
4
13
  /**
5
14
  * Emit the Zephyr application skeleton around the generated src/main.cpp.
6
15
  *