@typecad/framework-zephyr 1.0.0-alpha.11 → 1.0.0-alpha.13

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 (42) hide show
  1. package/dist/display/index.d.ts +1 -1
  2. package/dist/display/index.js +1 -1
  3. package/dist/display/profiles.d.ts +33 -0
  4. package/dist/display/profiles.js +36 -0
  5. package/dist/display/touch-adapter.d.ts +3 -4
  6. package/dist/display/touch-adapter.js +119 -16
  7. package/dist/display/ui-adapter.d.ts +4 -0
  8. package/dist/display/ui-adapter.js +348 -139
  9. package/dist/dt-config/kconfig.d.ts +5 -1
  10. package/dist/dt-config/kconfig.js +22 -5
  11. package/dist/dt-config/overlay.d.ts +26 -2
  12. package/dist/dt-config/overlay.js +138 -27
  13. package/dist/framework.manifest.d.ts +29 -28
  14. package/dist/framework.manifest.js +9 -3
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +5 -0
  17. package/dist/lowering/ble.js +3 -1
  18. package/dist/lowering/gpio.js +7 -3
  19. package/dist/strategy.d.ts +24 -0
  20. package/dist/strategy.js +326 -134
  21. package/dist/toolchain/debug-config.d.ts +43 -2
  22. package/dist/toolchain/debug-config.js +129 -17
  23. package/dist/toolchain/index.d.ts +13 -0
  24. package/dist/toolchain/index.js +104 -23
  25. package/dist/toolchain/scaffold.js +40 -18
  26. package/dist/toolchain/west-discover.js +4 -1
  27. package/package.json +4 -4
  28. package/src/display/index.ts +1 -1
  29. package/src/display/profiles.ts +63 -0
  30. package/src/display/touch-adapter.ts +119 -15
  31. package/src/display/ui-adapter.ts +357 -139
  32. package/src/dt-config/kconfig.ts +26 -6
  33. package/src/dt-config/overlay.ts +450 -298
  34. package/src/framework.manifest.ts +9 -3
  35. package/src/index.ts +6 -0
  36. package/src/lowering/ble.ts +3 -1
  37. package/src/lowering/gpio.ts +7 -3
  38. package/src/strategy.ts +355 -136
  39. package/src/toolchain/debug-config.ts +137 -14
  40. package/src/toolchain/index.ts +107 -24
  41. package/src/toolchain/scaffold.ts +39 -16
  42. package/src/toolchain/west-discover.ts +4 -1
@@ -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/init-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
+ }
@@ -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
@@ -19,7 +19,7 @@
19
19
  // ---------------------------------------------------------------------------
20
20
  import { spawnSync } from 'node:child_process';
21
21
  import { basename, dirname, join } from 'node:path';
22
- import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
22
+ import { readdirSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
23
23
  import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
24
24
  import { scaffoldZephyrProject, writeIfChanged } from './scaffold.js';
25
25
  import { westSpawn, buildEnv } from './west-spawn.js';
@@ -145,6 +145,29 @@ export function cleanseUploadOutput(runner, status, output) {
145
145
  }
146
146
  return output;
147
147
  }
148
+ /**
149
+ * Whether a failed `west build` output carries ninja's `dependency cycle`
150
+ * signature. Zephyr 4.3.99-dev snapshots have a regression
151
+ * (zephyrproject-rtos/zephyr#104757, fixed upstream by the #104784 revert,
152
+ * in v4.4+): after CMake re-runs from a .config change, the build dir's
153
+ * .ninja_deps records an `offsets.h -> offsets.c.obj -> offsets.h` cycle and
154
+ * ninja aborts with `ninja: error: dependency cycle: ...` before compiling
155
+ * anything. The cycle lives in the build dir, not the sources, so compile()
156
+ * recovers by deleting the dir and retrying once.
157
+ *
158
+ * Exported (pure) so the detection is unit-testable without spawning west.
159
+ */
160
+ export function isDependencyCycleFailure(output) {
161
+ return output.includes('dependency cycle');
162
+ }
163
+ /** stdout+stderr of a spawnSync result coerced to one string. Defensive about
164
+ * the buffer form (spawnSync only returns strings when `encoding` is set,
165
+ * which every call site here does — but the coercion costs nothing). */
166
+ function combinedSpawnOutput(result) {
167
+ const so = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
168
+ const se = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
169
+ return so + se;
170
+ }
148
171
  /**
149
172
  * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
150
173
  * The target board is carried via frameworkData.buildTarget; scaffolding
@@ -185,12 +208,17 @@ export const Toolchain = {
185
208
  // for either driver. Thread a non-default profile here only if a future
186
209
  // board carries a display node under a different nodelabel.
187
210
  const displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
211
+ // Touch controller kind comes from which DT nodelabel the emitted adapter
212
+ // references (FT6336U on I2C, XPT2046 on the display's SPI bus).
213
+ const usesTouch = uses('ft6336u') || uses('touch_');
214
+ const usesXpt = uses('xpt2046');
188
215
  const overlay = generateOverlay(chip, {
189
216
  usesI2c: uses('i2c_'),
190
217
  usesSpi: uses('spi_'),
191
218
  usesUart: uses('uart_'),
192
219
  usesDisplay,
193
- usesTouch: uses('ft6336u') || uses('touch_'),
220
+ usesTouch: usesTouch || usesXpt,
221
+ touchController: usesXpt ? 'xpt2046' : 'ft6336u',
194
222
  }, displayProfile);
195
223
  const overlayDir = join(projectRoot, 'boards');
196
224
  mkdirSync(overlayDir, { recursive: true });
@@ -272,27 +300,57 @@ export const Toolchain = {
272
300
  mosi: typeof spiPins?.mosi === 'number' ? spiPins.mosi : undefined,
273
301
  miso: typeof spiPins?.miso === 'number' ? spiPins.miso : undefined,
274
302
  backlightPin: typeof dispCfg.backlightPin === 'number' ? dispCfg.backlightPin : undefined,
303
+ tearingEffectPin: typeof dispCfg.tearingEffectPin === 'number' ? dispCfg.tearingEffectPin : undefined,
275
304
  }
276
305
  : undefined;
277
- // Extract touch pin wiring (irq/resetPin/sda/scl) from the config
278
- // display.touch section so the DT overlay wires the I2C bus + touch node.
306
+ // Extract touch pin wiring from the config display.touch section so the
307
+ // DT overlay wires the bus + touch node. I2C (FT6336U) carries
308
+ // irq/resetPin/sda/scl; SPI (XPT2046) carries irq/cs + the calibration
309
+ // range the xptek,xpt2046 binding requires.
279
310
  const touchCfg = dispCfg?.touch;
280
- const touchWiring = touchCfg
311
+ const isXpt = touchCfg?.library === 'XPT2046_Touchscreen';
312
+ const touchCal = touchCfg?.calibration;
313
+ const num = (v) => (typeof v === 'number' ? v : undefined);
314
+ let touchWiring = touchCfg
281
315
  ? {
282
- irq: typeof touchCfg.irq === 'number' ? touchCfg.irq : undefined,
283
- resetPin: typeof touchCfg.resetPin === 'number' ? touchCfg.resetPin : undefined,
284
- sda: typeof touchCfg.sda === 'number' ? touchCfg.sda : undefined,
285
- scl: typeof touchCfg.scl === 'number' ? touchCfg.scl : undefined,
316
+ controller: isXpt ? 'xpt2046' : 'ft6336u',
317
+ irq: num(touchCfg.irq),
318
+ resetPin: num(touchCfg.resetPin),
319
+ sda: num(touchCfg.sda),
320
+ scl: num(touchCfg.scl),
321
+ cs: num(touchCfg.cs),
322
+ calibration: touchCal
323
+ ? {
324
+ xMin: num(touchCal.xMin) ?? 0,
325
+ xMax: num(touchCal.xMax) ?? 4095,
326
+ yMin: num(touchCal.yMin) ?? 0,
327
+ yMax: num(touchCal.yMax) ?? 4095,
328
+ }
329
+ : undefined,
330
+ minPressure: num(touchCfg.minPressure),
286
331
  }
287
332
  : undefined;
333
+ // Touch controller kind for Kconfig (bus driver selection) and the DT
334
+ // node shape: from the config when available, else from the DT nodelabel
335
+ // the emitted adapter references. Forced onto touchWiring so a source
336
+ // scan match without a config section still emits the right node.
337
+ const usesXpt = isXpt || uses('xpt2046');
338
+ if (usesXpt) {
339
+ touchWiring = { controller: 'xpt2046', ...(touchWiring ?? {}) };
340
+ }
341
+ const overlayDiagnostics = [];
288
342
  const overlay = generateOverlay(chip, {
289
343
  usesI2c: uses('i2c_'),
290
344
  usesSpi: uses('spi_'),
291
345
  usesUart: uses('uart_'),
292
346
  usesDisplay,
293
- usesTouch: uses('ft6336u') || uses('touch_'),
347
+ usesTouch: uses('ft6336u') || uses('touch_') || usesXpt,
348
+ touchController: usesXpt ? 'xpt2046' : 'ft6336u',
294
349
  psram: o.psram,
295
- }, displayProfile, wiring, touchWiring);
350
+ }, displayProfile, wiring, touchWiring, overlayDiagnostics);
351
+ for (const d of overlayDiagnostics) {
352
+ console.warn(`overlay: ${d.message}`);
353
+ }
296
354
  const overlayDir = join(projectRoot, 'boards');
297
355
  mkdirSync(overlayDir, { recursive: true });
298
356
  // Write the board-specific overlay (the one west loads). Zephyr looks for
@@ -306,16 +364,23 @@ export const Toolchain = {
306
364
  // Use a stable build dir so incremental builds reuse the Ninja graph.
307
365
  // west defaults to <projectRoot>/build.
308
366
  const buildDir = join(projectRoot, 'build');
309
- // Nuke the build dir whenever a previous build exists. Zephyr's gen_offset
310
- // flow (offsets.h is generated FROM offsets.c.obj, while gen_offset.h makes
311
- // offsets.c include offsets.h) leaves a permanent `offsets.h ->
312
- // offsets.c.obj -> offsets.h` cycle in the .ninja_deps log after the first
313
- // incremental pass ninja then fails every later build with `dependency
314
- // cycle` even when nothing changed. This is a known Zephyr-on-Windows
315
- // issue; the reliable fix is a pristine build dir per build. Also nukes
316
- // when prj.conf/CMakeLists/overlay changed, so Kconfig symbols and
317
- // generated headers never diverge from a cached graph.
318
- if (configChanged || existsSync(join(buildDir, 'zephyr', 'zephyr.bin'))) {
367
+ // Reuse the build dir across builds so ninja recompiles only the changed
368
+ // app translation units and re-links a pristine configure + the
369
+ // ~280-target Zephyr library rebuild costs minutes on Windows
370
+ // (demo-shadcn measures 69s of ninja wall time, 448s of summed compile
371
+ // work, and every build redid all of it). Nuke it only when the generated
372
+ // config changed (prj.conf / CMakeLists content), the one path that must
373
+ // not reuse a cached graph: Zephyr 4.3.99-dev snapshots carry a
374
+ // regression (zephyrproject-rtos/zephyr#104757, fixed by the #104784
375
+ // revert on 2026-03-03, in v4.4+) where re-running CMake after a .config
376
+ // change records an `offsets.h -> offsets.c.obj -> offsets.h` cycle in
377
+ // .ninja_deps, after which every ninja run fails with `dependency cycle`.
378
+ // Plain source edits never reconfigure CMake, so they cannot trigger it —
379
+ // and the retry after the spawn below self-heals any path that still does.
380
+ // Board switches need no nuke here: `west build` is --pristine=auto by
381
+ // default and recreates the dir itself when -b <board> mismatches the
382
+ // cached board.
383
+ if (configChanged) {
319
384
  try {
320
385
  rmSync(buildDir, { recursive: true, force: true });
321
386
  }
@@ -344,10 +409,26 @@ export const Toolchain = {
344
409
  buildArgs.push(...userCmakeArgs);
345
410
  }
346
411
  const inv = westSpawn(buildArgs, { cwd: projectRoot, encoding: 'utf-8', timeout: BUILD_TIMEOUT_MS });
347
- const result = spawnSync(inv.command, inv.args, inv.options);
412
+ let result = spawnSync(inv.command, inv.args, inv.options);
413
+ // Self-heal the Zephyr 4.3.99 dep-cycle regression (see the nuke comment
414
+ // above): when the cached .ninja_deps carries the cycle, ninja aborts with
415
+ // `dependency cycle` before compiling anything. The cycle lives in the
416
+ // build dir, not the sources — one pristine retry clears it and the build
417
+ // proceeds. On fixed Zephyr (>=4.4) this never fires.
418
+ let pristineRetry = false;
419
+ if (result.status !== 0 && isDependencyCycleFailure(combinedSpawnOutput(result))) {
420
+ try {
421
+ rmSync(buildDir, { recursive: true, force: true });
422
+ }
423
+ catch { /* may not exist */ }
424
+ result = spawnSync(inv.command, inv.args, inv.options);
425
+ pristineRetry = true;
426
+ }
348
427
  const stdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
349
428
  const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
350
- const output = stdout + stderr;
429
+ const output = stdout + stderr + (pristineRetry
430
+ ? '\n[cuttlefish] dependency cycle detected in the cached build dir — retried with a pristine build'
431
+ : '');
351
432
  // Prefix the build log with how west was resolved, for transparency.
352
433
  const header = `Using west via ${inv.install.source}` +
353
434
  (inv.install.zephyrBase ? ` (ZEPHYR_BASE=${inv.install.zephyrBase})` : '') + '\n';
@@ -24,26 +24,35 @@ export function writeIfChanged(filePath, content) {
24
24
  writeFileSync(filePath, content);
25
25
  return true;
26
26
  }
27
+ /**
28
+ * Names of the cuttlefish-emitted C/C++ sources under src/ (top level only,
29
+ * matching the old `src/*.cpp src/*.c` glob; sorted so the generated
30
+ * CMakeLists.txt is stable across readdir orderings). Empty when no sources
31
+ * exist yet (first prepare call).
32
+ */
33
+ function listEmittedSources(srcDir) {
34
+ if (!existsSync(srcDir))
35
+ return [];
36
+ const names = readdirSync(srcDir).filter((name) => name.endsWith('.cpp') || name.endsWith('.c'));
37
+ names.sort();
38
+ return names;
39
+ }
27
40
  /**
28
41
  * Concatenate all emitted source under src/ so the scaffold can detect which
29
42
  * peripherals the program actually uses. The cuttlefish lowering emits
30
43
  * well-known driver API tokens (adc_read, spi_transceive, bt_*, …), so scanning
31
- * the post-transpile source is an authoritative usage signal — and it keeps the
32
- * scaffold self-contained (no need to thread analysis through the toolchain
44
+ * the post-transpile source is an authoritative usage signal — and it keeps
45
+ * the scaffold self-contained (no need to thread analysis through the toolchain
33
46
  * contract). Returns '' when no sources exist yet (first prepare call).
34
47
  */
35
48
  function readEmittedSources(srcDir) {
36
- if (!existsSync(srcDir))
37
- return '';
38
49
  let out = '';
39
- for (const name of readdirSync(srcDir)) {
40
- if (name.endsWith('.cpp') || name.endsWith('.c')) {
41
- try {
42
- out += readFileSync(join(srcDir, name), 'utf8');
43
- }
44
- catch {
45
- // ignore unreadable files
46
- }
50
+ for (const name of listEmittedSources(srcDir)) {
51
+ try {
52
+ out += readFileSync(join(srcDir, name), 'utf8');
53
+ }
54
+ catch {
55
+ // ignore unreadable files
47
56
  }
48
57
  }
49
58
  return out;
@@ -118,8 +127,13 @@ export function scaffoldZephyrProject(projectRoot, debug = false, userKconfig, p
118
127
  };
119
128
  let changed = false;
120
129
  // ── Root CMakeLists.txt ─────────────────────────────────────────────────
121
- // The canonical Zephyr CMake application. GLOB src/*.cpp so future multi-
122
- // file emits are picked up automatically; cuttlefish owns the file contents.
130
+ // The canonical Zephyr CMake application. The emitted source list is
131
+ // explicit (no file(GLOB CONFIGURE_DEPENDS ...)): CONFIGURE_DEPENDS puts a
132
+ // cmake.verify_globs step in the ninja graph that spawns CMake to re-check
133
+ // the glob on every build, and the scaffold already rewrites this file via
134
+ // writeIfChanged whenever the emitted file set changes — which flips
135
+ // configChanged and reconfigures with the new list baked in.
136
+ const sourceFiles = listEmittedSources(srcDir);
123
137
  const cmakeLists = [
124
138
  '# Auto-generated by @typecad/framework-zephyr from cuttlefish.config.ts.',
125
139
  'cmake_minimum_required(VERSION 3.20.0)',
@@ -128,10 +142,18 @@ export function scaffoldZephyrProject(projectRoot, debug = false, userKconfig, p
128
142
  '',
129
143
  'project(zephyr_app)',
130
144
  '',
131
- '# Collect cuttlefish-emitted sources.',
132
- 'file(GLOB app_sources src/*.cpp src/*.c)',
133
- '',
134
- 'target_sources(app PRIVATE ${app_sources})',
145
+ ...(sourceFiles.length > 0
146
+ ? [
147
+ '# Cuttlefish-emitted sources. This list is regenerated whenever the',
148
+ '# emitted file set changes (the scaffold rewrites CMakeLists.txt).',
149
+ 'target_sources(app PRIVATE',
150
+ ...sourceFiles.map((name) => ` src/${name}`),
151
+ ')',
152
+ ]
153
+ : [
154
+ '# No emitted sources yet — the scaffold regenerates this list on the',
155
+ '# next compile once src/ contains .cpp/.c files.',
156
+ ]),
135
157
  // When PSRAM is configured, define BOARD_HAS_PSRAM so the UI runtime's
136
158
  // PSRAM canvas allocator (ui_create_canvas_best) is compiled in.
137
159
  ...(psram ? ['', '# PSRAM enabled: activate the runtime PSRAM canvas paths.', 'target_compile_definitions(app PRIVATE BOARD_HAS_PSRAM)', ''] : ['']),
@@ -67,9 +67,12 @@ export function isZephyrBase(dir) {
67
67
  }
68
68
  // ── Strategy 1: `west` on PATH ──────────────────────────────────────────────
69
69
  export function discoverFromPath() {
70
+ // shell only on Windows (where.exe resolution through cmd) — an args array
71
+ // with shell: true triggers Node's DEP0190 deprecation warning on Linux/
72
+ // macOS, where `which` is a plain executable that needs no shell.
70
73
  const which = spawnSync(IS_WIN ? 'where' : 'which', ['west'], {
71
74
  encoding: 'utf8',
72
- shell: true,
75
+ shell: IS_WIN,
73
76
  windowsHide: true,
74
77
  });
75
78
  if (which.status !== 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typecad/framework-zephyr",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.13",
4
4
  "description": "TypeCAD framework package for the Zephyr RTOS — west/CMake build, devicetree-driven GPIO",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,11 +38,11 @@
38
38
  "test:hw:mqtt": "cd ../../tests/hardware && npm exec -- cuttlefish-test mqtt-client.test.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@typecad/cuttlefish": "1.0.0-alpha.11"
41
+ "@typecad/cuttlefish": "1.0.0-alpha.13"
42
42
  },
43
43
  "devDependencies": {
44
- "@typecad/expect": "1.0.0-alpha.11",
45
- "@typecad/board-xiao-nrf52840": "1.0.0-alpha.11",
44
+ "@typecad/expect": "1.0.0-alpha.13",
45
+ "@typecad/board-xiao-nrf52840": "1.0.0-alpha.13",
46
46
  "typescript": "^5.7.3"
47
47
  },
48
48
  "license": "MIT",
@@ -18,7 +18,7 @@ import { DEFAULT_ZEPHYR_DISPLAY_PROFILE, type ZephyrDisplayProfile } from './pro
18
18
  // and consumers can reach them from the package barrel.
19
19
  export { zephyrUiDisplayAdapter, zephyrDisplayAdapterGenerator } from './ui-adapter.js';
20
20
  export { zephyrTouchAdapter } from './touch-adapter.js';
21
- export { ZEPHYR_DISPLAY_PROFILES, DEFAULT_ZEPHYR_DISPLAY_PROFILE } from './profiles.js';
21
+ export { ZEPHYR_DISPLAY_PROFILES, DEFAULT_ZEPHYR_DISPLAY_PROFILE, BUILT_IN_PROFILES } from './profiles.js';
22
22
  export type { ZephyrDisplayProfile } from './profiles.js';
23
23
 
24
24
  export interface DisplayState {