@typecad/framework-zephyr 1.0.0-alpha.12 → 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.
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
19
19
  import { join, resolve, dirname, relative } from 'node:path';
20
+ import { ZephyrStrategy } from '../strategy.js';
20
21
 
21
22
  export interface DebugConfigOptions {
22
23
  /** Absolute path to the Zephyr project root (contains CMakeLists.txt + src/). */
@@ -34,8 +35,10 @@ export interface DebugConfigOptions {
34
35
  }
35
36
 
36
37
  /**
37
- * Resolve the GDB binary path for the target from the build cache. Zephyr
38
- * records ZEPHYR_SDK_INSTALL_DIR in CMakeCache.txt at configure time, and the
38
+ * Resolve the GDB binary path for the target from the build cache, falling
39
+ * back to a filesystem scan of known Zephyr SDK locations when no build
40
+ * exists yet (the create-time starter artifacts path). Zephyr records
41
+ * ZEPHYR_SDK_INSTALL_DIR in CMakeCache.txt at configure time, and the
39
42
  * xtensa GDB lives at <sdk>/xtensa-espressif_esp32s3_zephyr-elf/bin/... (note
40
43
  * the Zephyr-SDK naming, distinct from the ESP-IDF xtensa-esp32s3-elf-gdb).
41
44
  *
@@ -43,24 +46,94 @@ export interface DebugConfigOptions {
43
46
  * omits gdbPath and relies on Cortex-Debug's default resolution).
44
47
  */
45
48
  export function resolveGdbPath(buildDir: string, target: string): string | undefined {
49
+ void target; // toolchain dir is esp32s3-specific today; see gdbPathFromSdkRoot
46
50
  const cachePath = join(buildDir, 'CMakeCache.txt');
47
- if (!existsSync(cachePath)) return undefined;
48
- let sdk = '';
49
- try {
50
- const cache = readFileSync(cachePath, 'utf-8');
51
- const m = cache.match(/^ZEPHYR_SDK_INSTALL_DIR:PATH=(.+)$/m);
52
- if (m) sdk = m[1].trim();
53
- } catch {
54
- return undefined;
51
+ if (existsSync(cachePath)) {
52
+ try {
53
+ const cache = readFileSync(cachePath, 'utf-8');
54
+ const m = cache.match(/^ZEPHYR_SDK_INSTALL_DIR:PATH=(.+)$/m);
55
+ if (m) {
56
+ const fromCache = gdbPathFromSdkRoot(m[1].trim());
57
+ if (fromCache) return fromCache;
58
+ }
59
+ } catch {
60
+ // unreadable cache — fall through to the SDK scan
61
+ }
55
62
  }
56
- if (!sdk) return undefined;
57
- // The Zephyr SDK toolchain dir is target-specific. For esp32s3 it is
58
- // xtensa-espressif_esp32s3_zephyr-elf (verified against zephyr-sdk-0.17.4).
63
+ // No build dir yet (project just created): probe known SDK locations.
64
+ for (const sdkRoot of discoverZephyrSdkRoots()) {
65
+ const p = gdbPathFromSdkRoot(sdkRoot);
66
+ if (p) return p;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /** The esp32s3 xtensa GDB location inside a Zephyr SDK root (verified against
72
+ * zephyr-sdk-0.17.4). Returns a forward-slash absolute path or undefined. */
73
+ export function gdbPathFromSdkRoot(sdkRoot: string): string | undefined {
59
74
  const gdbName = 'xtensa-espressif_esp32s3_zephyr-elf-gdb.exe';
60
- const gdbPath = join(sdk, 'xtensa-espressif_esp32s3_zephyr-elf', 'bin', gdbName);
75
+ const gdbPath = join(sdkRoot, 'xtensa-espressif_esp32s3_zephyr-elf', 'bin', gdbName);
61
76
  return existsSync(gdbPath) ? gdbPath.replace(/\\/g, '/') : undefined;
62
77
  }
63
78
 
79
+ /** Compare two dotted version strings numerically (0.17.10 > 0.17.4). */
80
+ function compareSdkVersions(a: string, b: string): number {
81
+ const segsOf = (v: string): number[] => v.split('.').map((s) => parseInt(s, 10) || 0);
82
+ const aa = segsOf(a);
83
+ const bb = segsOf(b);
84
+ for (let i = 0; i < Math.max(aa.length, bb.length); i++) {
85
+ const d = (aa[i] ?? 0) - (bb[i] ?? 0);
86
+ if (d !== 0) return d;
87
+ }
88
+ return 0;
89
+ }
90
+
91
+ /**
92
+ * Probe the well-known Zephyr SDK install locations, newest version first:
93
+ * 1. $ZEPHYR_SDK_INSTALL_DIR (the var board.cmake reads)
94
+ * 2. <MAMBA_ROOT_PREFIX | ~/micromamba>/zephyr-sdk/zephyr-sdk-<ver> — the
95
+ * @typecad/zephyr-installer layout
96
+ * 3. ~/zephyr-sdk-<ver> — the standalone download layout
97
+ *
98
+ * Only roots that actually contain the esp32s3 GDB are useful to callers;
99
+ * this returns candidate roots (gdbPathFromSdkRoot does the existence check)
100
+ * so tests can inject home/env overrides.
101
+ */
102
+ export function discoverZephyrSdkRoots(opts?: {
103
+ home?: string;
104
+ env?: Record<string, string | undefined>;
105
+ }): string[] {
106
+ const env = opts?.env ?? process.env;
107
+ const home = opts?.home ?? (env.USERPROFILE || env.HOME || '');
108
+ const scanned: string[] = [];
109
+
110
+ const versionedDirs = (base: string): string[] => {
111
+ try {
112
+ return readdirSync(base)
113
+ .filter((d) => existsSync(join(base, d)) && d.startsWith('zephyr-sdk-'))
114
+ .map((d) => join(base, d));
115
+ } catch {
116
+ return []; // dir absent
117
+ }
118
+ };
119
+ const mambaRoot = env.MAMBA_ROOT_PREFIX || (home ? join(home, 'micromamba') : '');
120
+ if (mambaRoot) scanned.push(...versionedDirs(join(mambaRoot, 'zephyr-sdk')));
121
+ if (home) scanned.push(...versionedDirs(home));
122
+
123
+ // Scanned roots newest version first; the env var stays pinned first
124
+ // (explicit user intent outranks any discovered location).
125
+ scanned.sort((a, b) => {
126
+ const va = a.match(/zephyr-sdk-([\d.]+)/)?.[1] ?? '';
127
+ const vb = b.match(/zephyr-sdk-([\d.]+)/)?.[1] ?? '';
128
+ return compareSdkVersions(vb, va);
129
+ });
130
+ const roots = env.ZEPHYR_SDK_INSTALL_DIR
131
+ ? [env.ZEPHYR_SDK_INSTALL_DIR, ...scanned]
132
+ : scanned;
133
+ // De-duplicate (an env var may repeat a scan hit) preserving order.
134
+ return roots.filter((r, i) => roots.indexOf(r) === i);
135
+ }
136
+
64
137
  /**
65
138
  * Resolve the Espressif OpenOCD binary path. The esp32s3 needs the Espressif
66
139
  * OpenOCD fork (openocd-esp32) — not the Zephyr SDK's openocd and not a
@@ -397,3 +470,53 @@ export function writeDebugConfig(o: DebugConfigOptions): void {
397
470
  const task = buildTask(o);
398
471
  mergeJsonArrayEntry(join(vscodeDir, 'tasks.json'), 'tasks', 'label', task);
399
472
  }
473
+
474
+ /**
475
+ * The Zephyr app dir a `cuttlefish create` scaffold produces, relative to the
476
+ * project root: the scaffold fixes entry `./src/main.ts` + outDir `./out`, and
477
+ * the CLI resolves output.outDir against the ENTRY's directory (cli.ts), so
478
+ * the emitted app root — and therefore the ELF, build dir, and .cuttlefish/
479
+ * debug artifacts — always lands at `src/out`. Keep in sync with
480
+ * generateProjectConfig in @typecad/cuttlefish create/init-templates.ts.
481
+ */
482
+ const STARTER_SKETCH_REL = 'src/out';
483
+
484
+ /**
485
+ * Create-time starter debug artifacts. Called by the cuttlefish `create` flow
486
+ * (via the package's `writeProjectDebugArtifacts` export) so a fresh project
487
+ * has a working F5 before any build exists:
488
+ *
489
+ * The launch.json's preLaunchTask runs `cuttlefish build --compile --upload
490
+ * --debug`, which builds + flashes AND rewrites this same launch entry (merged
491
+ * by name) with the CMakeCache-resolved gdbPath — so the starter files upgrade
492
+ * themselves on the first debug build.
493
+ *
494
+ * No-ops (returns []) for targets without native GDB support (debugMode() !==
495
+ * 'gdb'); the gdb frame-filter script is skipped (no source map exists yet).
496
+ *
497
+ * Returns the workspace-relative paths written, for CLI reporting.
498
+ */
499
+ export function writeProjectDebugArtifacts(o: {
500
+ /** Absolute path to the cuttlefish project root (contains cuttlefish.config.ts). */
501
+ workspaceRoot: string;
502
+ /** The Zephyr board id from the project config (frameworkData.buildTarget). */
503
+ buildTarget?: string;
504
+ }): string[] {
505
+ if (new ZephyrStrategy().debugMode(o.buildTarget) !== 'gdb') return [];
506
+ const workspaceRoot = resolve(o.workspaceRoot);
507
+ const projectRoot = join(workspaceRoot, STARTER_SKETCH_REL);
508
+ writeDebugConfig({
509
+ projectRoot,
510
+ workspaceRoot,
511
+ sketchRel: STARTER_SKETCH_REL,
512
+ target: o.buildTarget ?? '',
513
+ // No build dir exists yet — resolveGdbPath falls back to probing known
514
+ // Zephyr SDK locations so gdbPath is still filled in when possible.
515
+ buildDir: join(projectRoot, 'build'),
516
+ });
517
+ return [
518
+ '.vscode/launch.json',
519
+ '.vscode/tasks.json',
520
+ `${STARTER_SKETCH_REL}/.cuttlefish/openocd.cfg`,
521
+ ];
522
+ }
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { basename, dirname, join } from 'node:path';
23
- import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
23
+ import { readdirSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
24
24
  import type { ToolchainOptions, CompileResult, UploadResult } from '@typecad/cuttlefish/api/shared';
25
25
  import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
26
26
  import { scaffoldZephyrProject, writeIfChanged } from './scaffold.js';
@@ -28,7 +28,7 @@ import { westSpawn, buildEnv } from './west-spawn.js';
28
28
  import { discoverWest } from './west-discover.js';
29
29
  import { writeDebugConfig, resolveDebugLocations } from './debug-config.js';
30
30
  import { ZephyrStrategy } from '../strategy.js';
31
- import { generateOverlay, type DisplayWiring, type TouchWiring } from '../dt-config/overlay.js';
31
+ import { generateOverlay, type DisplayWiring, type TouchWiring, type OverlayDiagnostic } from '../dt-config/overlay.js';
32
32
  import { chipForTarget } from '../chips/index.js';
33
33
  import { detectZephyrVersion, checkZephyrCompat, resolveBoardTarget } from './compat.js';
34
34
  import { DEFAULT_ZEPHYR_DISPLAY_PROFILE } from '../display/profiles.js';
@@ -167,6 +167,33 @@ export function cleanseUploadOutput(
167
167
  }
168
168
 
169
169
 
170
+ /**
171
+ * Whether a failed `west build` output carries ninja's `dependency cycle`
172
+ * signature. Zephyr 4.3.99-dev snapshots have a regression
173
+ * (zephyrproject-rtos/zephyr#104757, fixed upstream by the #104784 revert,
174
+ * in v4.4+): after CMake re-runs from a .config change, the build dir's
175
+ * .ninja_deps records an `offsets.h -> offsets.c.obj -> offsets.h` cycle and
176
+ * ninja aborts with `ninja: error: dependency cycle: ...` before compiling
177
+ * anything. The cycle lives in the build dir, not the sources, so compile()
178
+ * recovers by deleting the dir and retrying once.
179
+ *
180
+ * Exported (pure) so the detection is unit-testable without spawning west.
181
+ */
182
+ export function isDependencyCycleFailure(output: string): boolean {
183
+ return output.includes('dependency cycle');
184
+ }
185
+
186
+ /** stdout+stderr of a spawnSync result coerced to one string. Defensive about
187
+ * the buffer form (spawnSync only returns strings when `encoding` is set,
188
+ * which every call site here does — but the coercion costs nothing). */
189
+ function combinedSpawnOutput(
190
+ result: { stdout?: string | Buffer | null; stderr?: string | Buffer | null },
191
+ ): string {
192
+ const so = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
193
+ const se = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
194
+ return so + se;
195
+ }
196
+
170
197
  /**
171
198
  * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
172
199
  * The target board is carried via frameworkData.buildTarget; scaffolding
@@ -306,6 +333,7 @@ export const Toolchain = {
306
333
  mosi: typeof spiPins?.mosi === 'number' ? spiPins.mosi : undefined,
307
334
  miso: typeof spiPins?.miso === 'number' ? spiPins.miso : undefined,
308
335
  backlightPin: typeof dispCfg.backlightPin === 'number' ? dispCfg.backlightPin : undefined,
336
+ tearingEffectPin: typeof dispCfg.tearingEffectPin === 'number' ? dispCfg.tearingEffectPin : undefined,
309
337
  }
310
338
  : undefined;
311
339
  // Extract touch pin wiring from the config display.touch section so the
@@ -344,6 +372,7 @@ export const Toolchain = {
344
372
  if (usesXpt) {
345
373
  touchWiring = { controller: 'xpt2046', ...(touchWiring ?? {}) };
346
374
  }
375
+ const overlayDiagnostics: OverlayDiagnostic[] = [];
347
376
  const overlay = generateOverlay(chip, {
348
377
  usesI2c: uses('i2c_'),
349
378
  usesSpi: uses('spi_'),
@@ -352,7 +381,10 @@ export const Toolchain = {
352
381
  usesTouch: uses('ft6336u') || uses('touch_') || usesXpt,
353
382
  touchController: usesXpt ? 'xpt2046' : 'ft6336u',
354
383
  psram: o.psram,
355
- }, displayProfile, wiring, touchWiring);
384
+ }, displayProfile, wiring, touchWiring, overlayDiagnostics);
385
+ for (const d of overlayDiagnostics) {
386
+ console.warn(`overlay: ${d.message}`);
387
+ }
356
388
  const overlayDir = join(projectRoot, 'boards');
357
389
  mkdirSync(overlayDir, { recursive: true });
358
390
  // Write the board-specific overlay (the one west loads). Zephyr looks for
@@ -367,16 +399,23 @@ export const Toolchain = {
367
399
  // west defaults to <projectRoot>/build.
368
400
  const buildDir = join(projectRoot, 'build');
369
401
 
370
- // Nuke the build dir whenever a previous build exists. Zephyr's gen_offset
371
- // flow (offsets.h is generated FROM offsets.c.obj, while gen_offset.h makes
372
- // offsets.c include offsets.h) leaves a permanent `offsets.h ->
373
- // offsets.c.obj -> offsets.h` cycle in the .ninja_deps log after the first
374
- // incremental pass ninja then fails every later build with `dependency
375
- // cycle` even when nothing changed. This is a known Zephyr-on-Windows
376
- // issue; the reliable fix is a pristine build dir per build. Also nukes
377
- // when prj.conf/CMakeLists/overlay changed, so Kconfig symbols and
378
- // generated headers never diverge from a cached graph.
379
- if (configChanged || existsSync(join(buildDir, 'zephyr', 'zephyr.bin'))) {
402
+ // Reuse the build dir across builds so ninja recompiles only the changed
403
+ // app translation units and re-links a pristine configure + the
404
+ // ~280-target Zephyr library rebuild costs minutes on Windows
405
+ // (demo-shadcn measures 69s of ninja wall time, 448s of summed compile
406
+ // work, and every build redid all of it). Nuke it only when the generated
407
+ // config changed (prj.conf / CMakeLists content), the one path that must
408
+ // not reuse a cached graph: Zephyr 4.3.99-dev snapshots carry a
409
+ // regression (zephyrproject-rtos/zephyr#104757, fixed by the #104784
410
+ // revert on 2026-03-03, in v4.4+) where re-running CMake after a .config
411
+ // change records an `offsets.h -> offsets.c.obj -> offsets.h` cycle in
412
+ // .ninja_deps, after which every ninja run fails with `dependency cycle`.
413
+ // Plain source edits never reconfigure CMake, so they cannot trigger it —
414
+ // and the retry after the spawn below self-heals any path that still does.
415
+ // Board switches need no nuke here: `west build` is --pristine=auto by
416
+ // default and recreates the dir itself when -b <board> mismatches the
417
+ // cached board.
418
+ if (configChanged) {
380
419
  try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
381
420
  }
382
421
 
@@ -404,11 +443,24 @@ export const Toolchain = {
404
443
  buildArgs,
405
444
  { cwd: projectRoot, encoding: 'utf-8', timeout: BUILD_TIMEOUT_MS },
406
445
  );
407
- const result = spawnSync(inv.command, inv.args, inv.options);
446
+ let result = spawnSync(inv.command, inv.args, inv.options);
447
+ // Self-heal the Zephyr 4.3.99 dep-cycle regression (see the nuke comment
448
+ // above): when the cached .ninja_deps carries the cycle, ninja aborts with
449
+ // `dependency cycle` before compiling anything. The cycle lives in the
450
+ // build dir, not the sources — one pristine retry clears it and the build
451
+ // proceeds. On fixed Zephyr (>=4.4) this never fires.
452
+ let pristineRetry = false;
453
+ if (result.status !== 0 && isDependencyCycleFailure(combinedSpawnOutput(result))) {
454
+ try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
455
+ result = spawnSync(inv.command, inv.args, inv.options);
456
+ pristineRetry = true;
457
+ }
408
458
 
409
459
  const stdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
410
460
  const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
411
- const output = stdout + stderr;
461
+ const output = stdout + stderr + (pristineRetry
462
+ ? '\n[cuttlefish] dependency cycle detected in the cached build dir — retried with a pristine build'
463
+ : '');
412
464
  // Prefix the build log with how west was resolved, for transparency.
413
465
  const header = `Using west via ${inv.install.source}` +
414
466
  (inv.install.zephyrBase ? ` (ZEPHYR_BASE=${inv.install.zephyrBase})` : '') + '\n';
@@ -25,24 +25,34 @@ export function writeIfChanged(filePath: string, content: string): boolean {
25
25
  return true;
26
26
  }
27
27
 
28
+ /**
29
+ * Names of the cuttlefish-emitted C/C++ sources under src/ (top level only,
30
+ * matching the old `src/*.cpp src/*.c` glob; sorted so the generated
31
+ * CMakeLists.txt is stable across readdir orderings). Empty when no sources
32
+ * exist yet (first prepare call).
33
+ */
34
+ function listEmittedSources(srcDir: string): string[] {
35
+ if (!existsSync(srcDir)) return [];
36
+ const names = readdirSync(srcDir).filter((name) => name.endsWith('.cpp') || name.endsWith('.c'));
37
+ names.sort();
38
+ return names;
39
+ }
40
+
28
41
  /**
29
42
  * Concatenate all emitted source under src/ so the scaffold can detect which
30
43
  * peripherals the program actually uses. The cuttlefish lowering emits
31
44
  * well-known driver API tokens (adc_read, spi_transceive, bt_*, …), so scanning
32
- * the post-transpile source is an authoritative usage signal — and it keeps the
33
- * scaffold self-contained (no need to thread analysis through the toolchain
45
+ * the post-transpile source is an authoritative usage signal — and it keeps
46
+ * the scaffold self-contained (no need to thread analysis through the toolchain
34
47
  * contract). Returns '' when no sources exist yet (first prepare call).
35
48
  */
36
49
  function readEmittedSources(srcDir: string): string {
37
- if (!existsSync(srcDir)) return '';
38
50
  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
- } catch {
44
- // ignore unreadable files
45
- }
51
+ for (const name of listEmittedSources(srcDir)) {
52
+ try {
53
+ out += readFileSync(join(srcDir, name), 'utf8');
54
+ } catch {
55
+ // ignore unreadable files
46
56
  }
47
57
  }
48
58
  return out;
@@ -120,8 +130,13 @@ export function scaffoldZephyrProject(projectRoot: string, debug = false, userKc
120
130
  let changed = false;
121
131
 
122
132
  // ── Root CMakeLists.txt ─────────────────────────────────────────────────
123
- // The canonical Zephyr CMake application. GLOB src/*.cpp so future multi-
124
- // file emits are picked up automatically; cuttlefish owns the file contents.
133
+ // The canonical Zephyr CMake application. The emitted source list is
134
+ // explicit (no file(GLOB CONFIGURE_DEPENDS ...)): CONFIGURE_DEPENDS puts a
135
+ // cmake.verify_globs step in the ninja graph that spawns CMake to re-check
136
+ // the glob on every build, and the scaffold already rewrites this file via
137
+ // writeIfChanged whenever the emitted file set changes — which flips
138
+ // configChanged and reconfigures with the new list baked in.
139
+ const sourceFiles = listEmittedSources(srcDir);
125
140
  const cmakeLists = [
126
141
  '# Auto-generated by @typecad/framework-zephyr from cuttlefish.config.ts.',
127
142
  'cmake_minimum_required(VERSION 3.20.0)',
@@ -130,12 +145,18 @@ export function scaffoldZephyrProject(projectRoot: string, debug = false, userKc
130
145
  '',
131
146
  'project(zephyr_app)',
132
147
  '',
133
- '# Collect cuttlefish-emitted sources. CONFIGURE_DEPENDS makes CMake re-',
134
- '# check the glob when the source set changes (e.g. the transpiler removes',
135
- '# a stale entry), instead of linking a file list from the last configure.',
136
- 'file(GLOB app_sources CONFIGURE_DEPENDS src/*.cpp src/*.c)',
137
- '',
138
- 'target_sources(app PRIVATE ${app_sources})',
148
+ ...(sourceFiles.length > 0
149
+ ? [
150
+ '# Cuttlefish-emitted sources. This list is regenerated whenever the',
151
+ '# emitted file set changes (the scaffold rewrites CMakeLists.txt).',
152
+ 'target_sources(app PRIVATE',
153
+ ...sourceFiles.map((name) => ` src/${name}`),
154
+ ')',
155
+ ]
156
+ : [
157
+ '# No emitted sources yet — the scaffold regenerates this list on the',
158
+ '# next compile once src/ contains .cpp/.c files.',
159
+ ]),
139
160
  // When PSRAM is configured, define BOARD_HAS_PSRAM so the UI runtime's
140
161
  // PSRAM canvas allocator (ui_create_canvas_best) is compiled in.
141
162
  ...(psram ? ['', '# PSRAM enabled: activate the runtime PSRAM canvas paths.', 'target_compile_definitions(app PRIVATE BOARD_HAS_PSRAM)', ''] : ['']),