@typecad/framework-zephyr 1.0.0-alpha.13 → 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 (52) 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/dt-config/kconfig.d.ts +11 -0
  8. package/dist/dt-config/kconfig.js +1 -1
  9. package/dist/dt-config/overlay.js +85 -0
  10. package/dist/framework.manifest.d.ts +20 -30
  11. package/dist/framework.manifest.js +12 -3
  12. package/dist/lowering/adc.d.ts +7 -4
  13. package/dist/lowering/adc.js +25 -11
  14. package/dist/lowering/gpio.js +9 -6
  15. package/dist/lowering/mqtt.js +9 -1
  16. package/dist/lowering/pulse.js +7 -7
  17. package/dist/lowering/pwm.d.ts +21 -3
  18. package/dist/lowering/pwm.js +28 -4
  19. package/dist/lowering/spi.js +2 -2
  20. package/dist/lowering/tone.js +3 -2
  21. package/dist/lowering/wifi.js +28 -5
  22. package/dist/strategy.js +56 -4
  23. package/dist/toolchain/debug-config.js +1 -1
  24. package/dist/toolchain/index.d.ts +1 -1
  25. package/dist/toolchain/index.js +83 -8
  26. package/dist/toolchain/scaffold.d.ts +9 -0
  27. package/dist/toolchain/scaffold.js +45 -0
  28. package/dist/toolchain/west-discover.d.ts +4 -1
  29. package/dist/toolchain/west-discover.js +2 -0
  30. package/dist/toolchain/west-spawn.js +17 -5
  31. package/package.json +5 -5
  32. package/src/chips/controllers.ts +61 -12
  33. package/src/chips/resolve.ts +32 -5
  34. package/src/chips/types.ts +63 -12
  35. package/src/chips/xiao-ble.ts +82 -70
  36. package/src/dt-config/kconfig.ts +12 -1
  37. package/src/dt-config/overlay.ts +546 -450
  38. package/src/framework.manifest.ts +12 -3
  39. package/src/lowering/adc.ts +28 -12
  40. package/src/lowering/gpio.ts +9 -6
  41. package/src/lowering/mqtt.ts +9 -1
  42. package/src/lowering/pulse.ts +7 -7
  43. package/src/lowering/pwm.ts +29 -4
  44. package/src/lowering/spi.ts +2 -2
  45. package/src/lowering/tone.ts +3 -3
  46. package/src/lowering/wifi.ts +29 -5
  47. package/src/strategy.ts +52 -4
  48. package/src/toolchain/debug-config.ts +1 -1
  49. package/src/toolchain/index.ts +645 -565
  50. package/src/toolchain/scaffold.ts +43 -0
  51. package/src/toolchain/west-discover.ts +321 -316
  52. package/src/toolchain/west-spawn.ts +17 -5
@@ -1,565 +1,645 @@
1
- // ---------------------------------------------------------------------------
2
- // FrameworkToolchain impl for Zephyr (west / CMake)
3
- //
4
- // compile() scaffolds the project (idempotent) then runs `west build -b <board>`.
5
- // upload() runs `west flash`. monitor() runs a best-effort serial monitor.
6
- //
7
- // west resolution goes through westSpawn(), which finds a usable west without
8
- // requiring the user to have activated the Zephyr Python venv — it prefers
9
- // `<python> -m west` (robust cross-platform form) and injects ZEPHYR_BASE when
10
- // a SDK root is discovered. See west-discover.ts / west-spawn.ts.
11
- //
12
- // The board target is carried via frameworkData.buildTarget (populated as
13
- // ToolchainOptions.buildTarget by the cuttlefish CLI), defaulting to the
14
- // framework's canonical MVP target (xiao_ble).
15
- //
16
- // Mirrors framework-esp32/src/toolchain/index.ts structure: projectRoot derived
17
- // from outputDir, prepare is a no-op (scaffold happens in compile when the
18
- // target is known), GCC errors parsed via the shared parseCompileErrors helper.
19
- // ---------------------------------------------------------------------------
20
-
21
- import { spawnSync } from 'node:child_process';
22
- import { basename, dirname, join } from 'node:path';
23
- import { readdirSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
24
- import type { ToolchainOptions, CompileResult, UploadResult } from '@typecad/cuttlefish/api/shared';
25
- import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
26
- import { scaffoldZephyrProject, writeIfChanged } from './scaffold.js';
27
- import { westSpawn, buildEnv } from './west-spawn.js';
28
- import { discoverWest } from './west-discover.js';
29
- import { writeDebugConfig, resolveDebugLocations } from './debug-config.js';
30
- import { ZephyrStrategy } from '../strategy.js';
31
- import { generateOverlay, type DisplayWiring, type TouchWiring, type OverlayDiagnostic } from '../dt-config/overlay.js';
32
- import { chipForTarget } from '../chips/index.js';
33
- import { detectZephyrVersion, checkZephyrCompat, resolveBoardTarget } from './compat.js';
34
- import { DEFAULT_ZEPHYR_DISPLAY_PROFILE } from '../display/profiles.js';
35
-
36
- /** Default board target the framework's MVP canonical board. */
37
- const DEFAULT_BOARD = 'xiao_ble';
38
-
39
- function targetFromOptions(o: ToolchainOptions): string {
40
- // The cuttlefish CLI populates ToolchainOptions.buildTarget from
41
- // config.frameworkData.buildTarget. Accept frameworkData.target as an alias.
42
- const fcTarget = (o.frameworkConfig?.target as string | undefined);
43
- return (o.buildTarget as string | undefined) ?? fcTarget ?? DEFAULT_BOARD;
44
- }
45
-
46
- /**
47
- * Derive the Zephyr project root from the cuttlefish-emitted source path.
48
- *
49
- * Cuttlefish emits `src/main.cpp` under the output dir. The CLI passes
50
- * `sourcePath` = full path to `main.cpp` and `outputDir` = its parent (`src/`).
51
- * For Zephyr, the project root is the parent of `src/` — one level above
52
- * `outputDir`. Detect that shape and adjust; otherwise fall back to `outputDir`.
53
- */
54
- export function projectRootFromOptions(o: ToolchainOptions): string {
55
- const outDir = o.outputDir;
56
- if (basename(outDir) === 'src') {
57
- return dirname(outDir);
58
- }
59
- return outDir;
60
- }
61
-
62
- /**
63
- * west build timeout. Zephyr's first build fetches the toolchain modules and
64
- * configures CMake/Ninja, which can take several minutes; allow generous headroom.
65
- */
66
- const BUILD_TIMEOUT_MS = 600_000;
67
- const FLASH_TIMEOUT_MS = 120_000;
68
-
69
- /**
70
- * Build the `west flash` argument list for a board.
71
- *
72
- * Runner selection: each board's board.cmake declares a sensible default flash
73
- * runner for its hardware (xiao_ble → nrfutil, esp32* → esptool), and `west
74
- * flash` resolves it automatically. The framework only intervenes where the
75
- * board default needs an argument it can't infer:
76
- * - An explicit `zephyr.runner` (from cuttlefish.config.ts) always wins.
77
- * - ESP32 boards forward the port via `--esp-device` (esptool reads the
78
- * device from it); board.cmake still picks the runner.
79
- * - Every other board trusts the board.cmake default. Previously this forced
80
- * `--runner nrfjprog` for every non-ESP32 target, which broke boards whose
81
- * default is not nrfjprog (xiao_ble defaults to nrfutil) and required
82
- * Nordic J-Link tools that a USB-bootloader board does not have.
83
- *
84
- * Exported (pure) so the runner-selection contract is unit-testable without
85
- * spawning west.
86
- */
87
- export function buildFlashArgs(
88
- buildDir: string,
89
- board: string,
90
- userRunner: string | undefined,
91
- port: string | undefined,
92
- ): string[] {
93
- const args = ['flash', '-d', buildDir];
94
- if (userRunner) {
95
- args.push('--runner', userRunner);
96
- }
97
- if (port && board.startsWith('esp32')) {
98
- args.push('--esp-device', port);
99
- }
100
- return args;
101
- }
102
-
103
- /**
104
- * Classify a `west flash` result as success/failure.
105
- *
106
- * west's exit status is authoritative except for one known race in the uf2
107
- * runner on Windows: the UF2 bootloader reboots to run new firmware the instant
108
- * the file copy completes, unmounting the USB-MSC drive before `shutil.copy`'s
109
- * trailing `copymode`/chmod runs. That raises `OSError: [WinError 433] A
110
- * device which does not exist was specified` and makes west exit non-zero —
111
- * even though the firmware copied and flashed correctly (the LED blinks).
112
- *
113
- * The copy starting is logged ("Copying UF2 file to '<drive>'"); WinError 433
114
- * during `copymode` after that point proves the data write finished and the
115
- * drive only vanished on the metadata step. Treat that exact signature as
116
- * success so the upload isn't reported as a failure. Genuine uf2 failures
117
- * (no partition found, write errors before the copy) still surface as failures.
118
- *
119
- * Exported (pure) so the classification is unit-testable without spawning west.
120
- */
121
- export function classifyUploadResult(
122
- runner: string | undefined,
123
- status: number | null,
124
- output: string,
125
- ): boolean {
126
- if (status === 0) return true;
127
- if (isUf2DriveVanishRace(output)) return runner === 'uf2';
128
- return false;
129
- }
130
-
131
- /**
132
- * Whether `output` carries the benign UF2 copymode/WinError-433 race signature
133
- * (see classifyUploadResult). Centralized so classify + cleanse share one match.
134
- */
135
- function isUf2DriveVanishRace(output: string): boolean {
136
- return /Copying UF2 file to/.test(output)
137
- && /WinError 433/.test(output)
138
- && /copymode/.test(output);
139
- }
140
-
141
- /**
142
- * Cleanse the `west flash` output shown to the user.
143
- *
144
- * When classifyUploadResult has decided a non-zero west exit was the benign UF2
145
- * race (firmware copied, drive unmounted on the trailing chmod), the raw output
146
- * is a wall of Python traceback that reads like a hard failure. Drop everything
147
- * after the "Copying UF2 file to" line — i.e. the entire traceback — so a
148
- * successful flash reads as a success (the framework's ✓ Done follows). Non-race
149
- * output is returned untouched; genuine errors stay fully visible for diagnosis.
150
- *
151
- * Exported (pure) so the cleansing is unit-testable without spawning west.
152
- */
153
- export function cleanseUploadOutput(
154
- runner: string | undefined,
155
- status: number | null,
156
- output: string,
157
- ): string {
158
- if (status === 0) return output;
159
- if (runner === 'uf2' && isUf2DriveVanishRace(output)) {
160
- // Keep everything west printed up to and including "Copying UF2 file to",
161
- // then stop — everything after that is the drive-vanish traceback.
162
- const upto = output.match(/[\s\S]*Copying UF2 file to[^\n]*/);
163
- const head = upto ? upto[0] : '-- west flash: using runner uf2';
164
- return head;
165
- }
166
- return output;
167
- }
168
-
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
-
197
- /**
198
- * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
199
- * The target board is carried via frameworkData.buildTarget; scaffolding
200
- * happens at compile time when the target is known.
201
- */
202
- export const Toolchain = {
203
- prepare(outputDir: string, entryPoint: string): void {
204
- // Write the DT overlay for the default board (the real target is known at
205
- // compile time; prepare runs before compile, so use the default board id).
206
- // The overlay is additive and idempotent; compile re-runs prepare-equivalent
207
- // logic in scaffold via the usage scan. Mirrors how Arduino's library
208
- // resolution is a pre-build artifact step.
209
- const projectRoot = basename(outputDir) === 'src' ? dirname(outputDir) : outputDir;
210
- const board = DEFAULT_BOARD;
211
- const chip = chipForTarget(board);
212
- // Scan the emitted source for usage tokens (same authoritative signal the
213
- // scaffold uses). entryPoint is the path to main.cpp; its dir is src/.
214
- const srcDir = dirname(entryPoint);
215
- let src = '';
216
- try {
217
- for (const name of readdirSync(srcDir)) {
218
- if (name.endsWith('.cpp') || name.endsWith('.c')) {
219
- src += readFileSync(join(srcDir, name), 'utf8');
220
- }
221
- }
222
- } catch { /* src may not exist yet on first prepare */ }
223
- const uses = (t: string): boolean => src.includes(t);
224
- // Display usage tokens: the minimal GFX runtime (display_write/_fill_rect)
225
- // and the UI display adapter (display_init / __tc_display_dev /
226
- // DEVICE_DT_GET on the display nodelabel). Both paths need the DT overlay
227
- // to enable the display node.
228
- const usesDisplay = uses('display_write') || uses('display_init')
229
- || uses('display_fill_rect') || uses('__tc_display_dev')
230
- || uses('CuttlefishDisplayTarget');
231
- // Both registered Zephyr display profiles use dtLabel 'display0', so the
232
- // default profile's overlay block (&display0 { status="okay" }) is correct
233
- // for either driver. Thread a non-default profile here only if a future
234
- // board carries a display node under a different nodelabel.
235
- const displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
236
- // Touch controller kind comes from which DT nodelabel the emitted adapter
237
- // references (FT6336U on I2C, XPT2046 on the display's SPI bus).
238
- const usesTouch = uses('ft6336u') || uses('touch_');
239
- const usesXpt = uses('xpt2046');
240
- const overlay = generateOverlay(chip, {
241
- usesI2c: uses('i2c_'),
242
- usesSpi: uses('spi_'),
243
- usesUart: uses('uart_'),
244
- usesDisplay,
245
- usesTouch: usesTouch || usesXpt,
246
- touchController: usesXpt ? 'xpt2046' : 'ft6336u',
247
- }, displayProfile);
248
- const overlayDir = join(projectRoot, 'boards');
249
- mkdirSync(overlayDir, { recursive: true });
250
- writeIfChanged(join(overlayDir, `${board}.overlay`), overlay);
251
- },
252
-
253
- compile(o: ToolchainOptions): CompileResult {
254
- const projectRoot = projectRootFromOptions(o);
255
- const rawBoard = targetFromOptions(o);
256
-
257
- // Fail fast on an incompatible Zephyr (clear message vs. a cryptic west/
258
- // CMake board error), then normalize the board target for the installed
259
- // version Zephyr 4.3+ rejects bare multi-core board names, so a stale
260
- // config (esp32s3_devkitc) is rewritten to the qualified form
261
- // (esp32s3_devkitc/esp32s3/procpu). See toolchain/compat.ts.
262
- const zephyrVersion = detectZephyrVersion();
263
- const compat = checkZephyrCompat(zephyrVersion);
264
- if (compat.status === 'out-of-range') {
265
- throw new Error(
266
- `Zephyr ${zephyrVersion} is outside the supported range (${compat.range}) for @typecad/framework-zephyr. ` +
267
- `Set ZEPHYR_BASE to a compatible Zephyr checkout, or install one via '@typecad/zephyr-installer'.`,
268
- );
269
- }
270
- if (compat.status === 'undetectable') {
271
- console.warn(
272
- `! Could not detect the installed Zephyr version (is ZEPHYR_BASE set?). ` +
273
- `Skipping compat check; declared range is ${compat.range}.`,
274
- );
275
- }
276
- const board = resolveBoardTarget(rawBoard, zephyrVersion);
277
-
278
- const debugMode = new ZephyrStrategy().debugMode(board);
279
- const isGdbDebug = o.debug === true && debugMode === 'gdb';
280
- const zc = o.zephyrConfig as Record<string, unknown> | undefined;
281
- const userKconfig = zc?.kconfig as Record<string, string> | undefined;
282
- const configChanged = scaffoldZephyrProject(projectRoot, isGdbDebug, userKconfig, o.psram);
283
-
284
- // Regenerate the DT overlay for the ACTUAL target board. prepare() writes
285
- // it for the default board (the real target is unknown until compile), so
286
- // the <default>.overlay it wrote does not match `west build -b <board>`.
287
- // Zephyr auto-detects boards/<board>.overlay under APPLICATION_CONFIG_DIR.
288
- try {
289
- const chip = chipForTarget(board);
290
- const srcDir = join(projectRoot, 'src');
291
- let src = '';
292
- try {
293
- for (const name of readdirSync(srcDir)) {
294
- if (name.endsWith('.cpp') || name.endsWith('.c')) {
295
- src += readFileSync(join(srcDir, name), 'utf-8');
296
- }
297
- }
298
- } catch { /* src may not exist */ }
299
- const uses = (t: string): boolean => src.includes(t);
300
- const usesDisplay = uses('display_write') || uses('display_init')
301
- || uses('display_fill_rect') || uses('__tc_display_dev')
302
- || uses('CuttlefishDisplayTarget');
303
- // Derive the display dimensions from the emitted adapter code
304
- // (display_width/height return the profile's w/h). This ensures the DT
305
- // overlay's width/height match the panel the adapter targets, not the
306
- // default profile — critical for drivers like ST7796S that initialize
307
- // the panel geometry from the DT node.
308
- let displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
309
- if (usesDisplay) {
310
- const wMatch = src.match(/display_width\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
311
- const hMatch = src.match(/display_height\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
312
- if (wMatch && hMatch) {
313
- displayProfile = {
314
- ...DEFAULT_ZEPHYR_DISPLAY_PROFILE,
315
- width: parseInt(wMatch[1], 10),
316
- height: parseInt(hMatch[1], 10),
317
- };
318
- }
319
- }
320
- // Extract display pin wiring (cs/dc/rst/spiFrequency/spiPins) from the
321
- // config display section so the DT overlay wires the MIPI DBI bridge to
322
- // the correct GPIOs + SPI bus pins.
323
- const dispCfg = o.display as Record<string, unknown> | undefined;
324
- const spiPins = (dispCfg?.spiPins ?? undefined) as
325
- { sck?: unknown; mosi?: unknown; miso?: unknown } | undefined;
326
- const wiring: DisplayWiring | undefined = dispCfg
327
- ? {
328
- cs: typeof dispCfg.cs === 'number' ? dispCfg.cs : undefined,
329
- dc: typeof dispCfg.dc === 'number' ? dispCfg.dc : undefined,
330
- rst: typeof dispCfg.rst === 'number' ? dispCfg.rst : undefined,
331
- spiFrequency: typeof dispCfg.spiFrequency === 'number' ? dispCfg.spiFrequency : undefined,
332
- sck: typeof spiPins?.sck === 'number' ? spiPins.sck : undefined,
333
- mosi: typeof spiPins?.mosi === 'number' ? spiPins.mosi : undefined,
334
- miso: typeof spiPins?.miso === 'number' ? spiPins.miso : undefined,
335
- backlightPin: typeof dispCfg.backlightPin === 'number' ? dispCfg.backlightPin : undefined,
336
- tearingEffectPin: typeof dispCfg.tearingEffectPin === 'number' ? dispCfg.tearingEffectPin : undefined,
337
- }
338
- : undefined;
339
- // Extract touch pin wiring from the config display.touch section so the
340
- // DT overlay wires the bus + touch node. I2C (FT6336U) carries
341
- // irq/resetPin/sda/scl; SPI (XPT2046) carries irq/cs + the calibration
342
- // range the xptek,xpt2046 binding requires.
343
- const touchCfg = dispCfg?.touch as Record<string, unknown> | undefined;
344
- const isXpt = touchCfg?.library === 'XPT2046_Touchscreen';
345
- const touchCal = touchCfg?.calibration as
346
- { xMin?: unknown; xMax?: unknown; yMin?: unknown; yMax?: unknown } | undefined;
347
- const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined);
348
- let touchWiring: TouchWiring | undefined = touchCfg
349
- ? {
350
- controller: isXpt ? 'xpt2046' : 'ft6336u',
351
- irq: num(touchCfg.irq),
352
- resetPin: num(touchCfg.resetPin),
353
- sda: num(touchCfg.sda),
354
- scl: num(touchCfg.scl),
355
- cs: num(touchCfg.cs),
356
- calibration: touchCal
357
- ? {
358
- xMin: num(touchCal.xMin) ?? 0,
359
- xMax: num(touchCal.xMax) ?? 4095,
360
- yMin: num(touchCal.yMin) ?? 0,
361
- yMax: num(touchCal.yMax) ?? 4095,
362
- }
363
- : undefined,
364
- minPressure: num(touchCfg.minPressure),
365
- }
366
- : undefined;
367
- // Touch controller kind for Kconfig (bus driver selection) and the DT
368
- // node shape: from the config when available, else from the DT nodelabel
369
- // the emitted adapter references. Forced onto touchWiring so a source
370
- // scan match without a config section still emits the right node.
371
- const usesXpt = isXpt || uses('xpt2046');
372
- if (usesXpt) {
373
- touchWiring = { controller: 'xpt2046', ...(touchWiring ?? {}) };
374
- }
375
- const overlayDiagnostics: OverlayDiagnostic[] = [];
376
- const overlay = generateOverlay(chip, {
377
- usesI2c: uses('i2c_'),
378
- usesSpi: uses('spi_'),
379
- usesUart: uses('uart_'),
380
- usesDisplay,
381
- usesTouch: uses('ft6336u') || uses('touch_') || usesXpt,
382
- touchController: usesXpt ? 'xpt2046' : 'ft6336u',
383
- psram: o.psram,
384
- }, displayProfile, wiring, touchWiring, overlayDiagnostics);
385
- for (const d of overlayDiagnostics) {
386
- console.warn(`overlay: ${d.message}`);
387
- }
388
- const overlayDir = join(projectRoot, 'boards');
389
- mkdirSync(overlayDir, { recursive: true });
390
- // Write the board-specific overlay (the one west loads). Zephyr looks for
391
- // boards/<board_id>.overlay under APPLICATION_CONFIG_DIR use the bare
392
- // board id (before any hardware-qualifier suffix, e.g. 'esp32_devkitc'
393
- // not the full 'esp32_devkitc/esp32/procpu' target string).
394
- const boardId = board.split('/')[0];
395
- writeIfChanged(join(overlayDir, `${boardId}.overlay`), overlay);
396
- } catch { /* best-effort overlay regen; the build surfaces DT errors */ }
397
-
398
- // Use a stable build dir so incremental builds reuse the Ninja graph.
399
- // west defaults to <projectRoot>/build.
400
- const buildDir = join(projectRoot, 'build');
401
-
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) {
419
- try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
420
- }
421
-
422
- const buildArgs = ['build', '-b', board, '-d', buildDir, projectRoot];
423
- // Explicitly pass the generated DT overlay. Zephyr's auto-detection of
424
- // boards/<board>.overlay fails for hardware-qualified targets (e.g.
425
- // esp32_devkitc/esp32/procpu) because the FILE_SUFFIX matching doesn't
426
- // resolve — passing -DDTC_OVERLAY_FILE forces it unconditionally.
427
- const boardId = board.split('/')[0];
428
- const overlayPath = join(projectRoot, 'boards', `${boardId}.overlay`);
429
- try {
430
- if (readFileSync(overlayPath, 'utf-8').length > 0) {
431
- // CMake parses backslashes as escapes — use forward slashes so the
432
- // Windows path survives the -D argument intact.
433
- buildArgs.push('--', `-DDTC_OVERLAY_FILE=${overlayPath.replace(/\\/g, '/')}`);
434
- }
435
- } catch { /* no overlay — let Zephyr auto-detect or build without one */ }
436
- // Append user cmake args from cuttlefish.config.ts zephyr.cmakeArgs.
437
- const userCmakeArgs = zc?.cmakeArgs as string[] | undefined;
438
- if (userCmakeArgs && userCmakeArgs.length > 0) {
439
- if (!buildArgs.includes('--')) buildArgs.push('--');
440
- buildArgs.push(...userCmakeArgs);
441
- }
442
- const inv = westSpawn(
443
- buildArgs,
444
- { cwd: projectRoot, encoding: 'utf-8', timeout: BUILD_TIMEOUT_MS },
445
- );
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
- }
458
-
459
- const stdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
460
- const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
461
- const output = stdout + stderr + (pristineRetry
462
- ? '\n[cuttlefish] dependency cycle detected in the cached build dir — retried with a pristine build'
463
- : '');
464
- // Prefix the build log with how west was resolved, for transparency.
465
- const header = `Using west via ${inv.install.source}` +
466
- (inv.install.zephyrBase ? ` (ZEPHYR_BASE=${inv.install.zephyrBase})` : '') + '\n';
467
-
468
- // After a successful build in gdb mode (--debug on a probe-capable target),
469
- // write the VS Code launch.json + tasks.json + gdb-script artifacts so F5
470
- // attaches GDB to the chip's debug probe. Non-fatal on failure — a missing
471
- // artifact doesn't block the build. Mirrors the deleted framework-esp32
472
- // toolchain compile() debug-config wiring.
473
- if (result.status === 0 && isGdbDebug) {
474
- try {
475
- const { workspaceRoot, sketchRel } = resolveDebugLocations(projectRoot);
476
- writeDebugConfig({
477
- projectRoot,
478
- workspaceRoot,
479
- sketchRel,
480
- target: board,
481
- buildDir,
482
- sourceMapPath: join(dirname(o.sourcePath), `${basename(o.sourcePath)}.thcppmap.json`),
483
- });
484
- } catch (e) {
485
- console.warn(`[cuttlefish] gdb debug config generation failed: ${(e as Error).message}`);
486
- }
487
- }
488
-
489
- return {
490
- success: result.status === 0,
491
- output: header + output,
492
- errors: parseCompileErrors(output, o.sourcePath),
493
- };
494
- },
495
-
496
- upload(o: ToolchainOptions): UploadResult {
497
- const projectRoot = projectRootFromOptions(o);
498
- const buildDir = join(projectRoot, 'build');
499
- const board = targetFromOptions(o);
500
- const zc = o.zephyrConfig as Record<string, unknown> | undefined;
501
- const runner = zc?.runner as string | undefined;
502
- const args = buildFlashArgs(buildDir, board, runner, o.port);
503
-
504
- const inv = westSpawn(args, {
505
- cwd: projectRoot,
506
- encoding: 'utf-8',
507
- timeout: FLASH_TIMEOUT_MS,
508
- });
509
- const result = spawnSync(inv.command, inv.args, inv.options);
510
-
511
- const fstdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
512
- const fstderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
513
- const raw = fstdout + fstderr;
514
- return {
515
- success: classifyUploadResult(runner, result.status, raw),
516
- output: cleanseUploadOutput(runner, result.status, raw),
517
- };
518
- },
519
-
520
- monitor(o: ToolchainOptions): void {
521
- // Serial monitor over USB-CDC. Zephyr does NOT ship a `west serial`
522
- // subcommand (it's not a real west command — invoking it errors with
523
- // "unknown command"). The discovered west install's venv carries pyserial,
524
- // so run its bundled miniterm directly: `python -m serial.tools.miniterm`.
525
- // That is the same cross-platform terminal pyserial provides in ESP-IDF's
526
- // idf.py monitor, and it inherits stdio so Ctrl+C exits cleanly.
527
- if (!o.port) {
528
- throw new Error(
529
- 'A serial port is required to monitor. Pass --port <COMx/ttyX>.',
530
- );
531
- }
532
- // ESP32 USB-CDC console runs at 115200 (the Zephyr ESP32 board default).
533
- // The CLI's generic default of 9600 is wrong for this target; honor an
534
- // explicit --baud / config.console.baudRate when given, else 115200.
535
- const baud = o.baud ?? 115200;
536
- const install = discoverWest();
537
- const py = install?.pythonExecutable ?? process.env.PYTHON ?? 'python';
538
- // Reuse west-spawn's env builder (prepends the venv bin dir to PATH so the
539
- // python we spawn resolves pyserial from the same venv). Falls back to the
540
- // process env when no install is discovered.
541
- const env = install ? buildEnv(install) : process.env;
542
- spawnSync(py, ['-m', 'serial.tools.miniterm', o.port, String(baud)], {
543
- cwd: projectRootFromOptions(o),
544
- env,
545
- stdio: 'inherit',
546
- });
547
- },
548
-
549
- debug(o: ToolchainOptions): void {
550
- // Launch an interactive GDB session for the last build. `west debug`
551
- // auto-resolves the runner (openocd for esp32s3, nrfjprog/jlink for nRF)
552
- // and the GDB binary from the build dir's CMakeCache/board.cmake — no
553
- // hand-authored gdbinit needed. Inherits stdio so GDB runs interactively.
554
- // (Not invoked by the standard build/compile flow; powers an explicit
555
- // debug-attach entry point for terminal-driven debugging without VS Code.)
556
- const projectRoot = projectRootFromOptions(o);
557
- const buildDir = join(projectRoot, 'build');
558
- const inv = westSpawn(['debug', '-d', buildDir], {
559
- cwd: projectRoot,
560
- encoding: 'utf-8',
561
- stdio: 'inherit',
562
- });
563
- spawnSync(inv.command, inv.args, inv.options);
564
- },
565
- };
1
+ // ---------------------------------------------------------------------------
2
+ // FrameworkToolchain impl for Zephyr (west / CMake)
3
+ //
4
+ // compile() scaffolds the project (idempotent) then runs `west build -b <board>`.
5
+ // upload() runs `west flash`. monitor() runs a best-effort serial monitor.
6
+ //
7
+ // west resolution goes through westSpawn(), which finds a usable west without
8
+ // requiring the user to have activated the Zephyr Python venv — it prefers
9
+ // `<python> -m west` (robust cross-platform form) and injects ZEPHYR_BASE when
10
+ // a SDK root is discovered. See west-discover.ts / west-spawn.ts.
11
+ //
12
+ // The board target is carried via frameworkData.buildTarget (populated as
13
+ // ToolchainOptions.buildTarget by the cuttlefish CLI), defaulting to the
14
+ // framework's canonical MVP target (xiao_ble).
15
+ //
16
+ // Mirrors framework-esp32/src/toolchain/index.ts structure: projectRoot derived
17
+ // from outputDir, prepare is a no-op (scaffold happens in compile when the
18
+ // target is known), GCC errors parsed via the shared parseCompileErrors helper.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { spawnSync } from 'node:child_process';
22
+ import { basename, dirname, join } from 'node:path';
23
+ import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
24
+ import type { ToolchainOptions, CompileResult, UploadResult } from '@typecad/cuttlefish/api/shared';
25
+ import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
26
+ import { scaffoldZephyrProject, writeIfChanged, appendLibraryOverlayFragments } from './scaffold.js';
27
+ import { westSpawn, buildEnv } from './west-spawn.js';
28
+ import { discoverWest } from './west-discover.js';
29
+ import { writeDebugConfig, resolveDebugLocations } from './debug-config.js';
30
+ import { ZephyrStrategy } from '../strategy.js';
31
+ import { generateOverlay, type DisplayWiring, type TouchWiring, type OverlayDiagnostic } from '../dt-config/overlay.js';
32
+ import { chipForTarget } from '../chips/index.js';
33
+ import { resolveChipFromBoard } from '../chips/resolve.js';
34
+ import type { ZephyrChipDescriptor } from '../chips/types.js';
35
+ import { pwmDtAliasToken } from '../lowering/pwm.js';
36
+ import { detectZephyrVersion, checkZephyrCompat, resolveBoardTarget } from './compat.js';
37
+ import { DEFAULT_ZEPHYR_DISPLAY_PROFILE } from '../display/profiles.js';
38
+
39
+ /** Default board target — the framework's MVP canonical board. */
40
+ const DEFAULT_BOARD = 'xiao_ble';
41
+
42
+ /**
43
+ * Resolve the chip for a build the same way the strategy does at emit time —
44
+ * from the board constants the transpile persisted next to the emitted
45
+ * source (`board-constants.json`), falling back to the hardcoded registry.
46
+ * Board-package chips (rpi_pico, esp32c3/c6, blackpill) exist only in their
47
+ * board packages; the registry fallback would silently resolve them to the
48
+ * XIAO default and the overlay generator would emit wrong controller labels
49
+ * (e.g. `&uart0` on an STM32, whose node is `usart1`).
50
+ */
51
+ function chipForBuild(projectRoot: string, board: string): ZephyrChipDescriptor {
52
+ try {
53
+ // The transpile writes the constants into the emit outDir, which is
54
+ // <projectRoot>/src for the standard layout (basename 'src' collapsed by
55
+ // projectRootFromOptions); check both locations.
56
+ const bcPath = [join(projectRoot, 'src', 'board-constants.json'), join(projectRoot, 'board-constants.json')]
57
+ .find(p => existsSync(p));
58
+ if (bcPath) {
59
+ const raw = JSON.parse(readFileSync(bcPath, 'utf8')) as Record<string, string | number | boolean>;
60
+ const fromBoard = resolveChipFromBoard(new Map(Object.entries(raw)));
61
+ if (fromBoard) return fromBoard;
62
+ }
63
+ } catch { /* fall back to the registry below */ }
64
+ return chipForTarget(board);
65
+ }
66
+
67
+ /**
68
+ * HAL pins the emitted sources read via adc.* — scanned from the emitted
69
+ * `__tc_adc<N>_setup()` call sites (N = channel index, mapped back to the HAL
70
+ * pin via the chip descriptor). Feeds the overlay's ADC pinctrl rewrite: on
71
+ * SoCs that mux ADC pads via pinctrl (STM32), only the read channels are
72
+ * switched to analog mode.
73
+ */
74
+ function scanAdcReadPins(src: string, chip: ZephyrChipDescriptor): number[] {
75
+ const pins: number[] = [];
76
+ // Match CALL SITES only (`__tc_adc<N>_setup()` with empty parens) the
77
+ // setup definitions emitted by adcInitLines have a `(void)` parameter list
78
+ // and would otherwise mark every descriptor channel as used.
79
+ for (const m of src.matchAll(/__tc_adc(\d+)_setup\(\)/g)) {
80
+ const ch = Number(m[1]);
81
+ const c = chip.adc?.channels.find((x) => x.channel === ch);
82
+ if (c && !pins.includes(c.pin)) pins.push(c.pin);
83
+ }
84
+ return pins;
85
+ }
86
+
87
+ /**
88
+ * HAL pins the emitted sources drive with pwm.* — the emitted source
89
+ * references each used spec as `__tc_pwm_<alias token>` (pwmVarName in
90
+ * lowering/pwm.ts), and the lowering only emits specs for driven pins, so
91
+ * var-presence is the authoritative signal. Feeds the overlay's per-pin
92
+ * pwm-leds gating (no dead DT channels).
93
+ */
94
+ function scanPwmUsedPins(src: string, chip: ZephyrChipDescriptor): number[] {
95
+ return (chip.pwm?.specs ?? [])
96
+ .filter((s) => src.includes(`__tc_pwm_${pwmDtAliasToken(s)}`))
97
+ .map((s) => s.pin);
98
+ }
99
+
100
+ function targetFromOptions(o: ToolchainOptions): string {
101
+ // The cuttlefish CLI populates ToolchainOptions.buildTarget from
102
+ // config.frameworkData.buildTarget. Accept frameworkData.target as an alias.
103
+ const fcTarget = (o.frameworkConfig?.target as string | undefined);
104
+ return (o.buildTarget as string | undefined) ?? fcTarget ?? DEFAULT_BOARD;
105
+ }
106
+
107
+ /**
108
+ * Derive the Zephyr project root from the cuttlefish-emitted source path.
109
+ *
110
+ * Cuttlefish emits `src/main.cpp` under the output dir. The CLI passes
111
+ * `sourcePath` = full path to `main.cpp` and `outputDir` = its parent (`src/`).
112
+ * For Zephyr, the project root is the parent of `src/` — one level above
113
+ * `outputDir`. Detect that shape and adjust; otherwise fall back to `outputDir`.
114
+ */
115
+ export function projectRootFromOptions(o: ToolchainOptions): string {
116
+ const outDir = o.outputDir;
117
+ if (basename(outDir) === 'src') {
118
+ return dirname(outDir);
119
+ }
120
+ return outDir;
121
+ }
122
+
123
+ /**
124
+ * west build timeout. Zephyr's first build fetches the toolchain modules and
125
+ * configures CMake/Ninja, which can take several minutes; allow generous headroom.
126
+ */
127
+ const BUILD_TIMEOUT_MS = 600_000;
128
+ const FLASH_TIMEOUT_MS = 120_000;
129
+
130
+ /**
131
+ * Build the `west flash` argument list for a board.
132
+ *
133
+ * Runner selection: each board's board.cmake declares a sensible default flash
134
+ * runner for its hardware (xiao_ble → nrfutil, esp32* → esptool), and `west
135
+ * flash` resolves it automatically. The framework only intervenes where the
136
+ * board default needs an argument it can't infer:
137
+ * - An explicit `zephyr.runner` (from cuttlefish.config.ts) always wins.
138
+ * - ESP32 boards forward the port via `--esp-device` (esptool reads the
139
+ * device from it); board.cmake still picks the runner.
140
+ * - Every other board trusts the board.cmake default. Previously this forced
141
+ * `--runner nrfjprog` for every non-ESP32 target, which broke boards whose
142
+ * default is not nrfjprog (xiao_ble defaults to nrfutil) and required
143
+ * Nordic J-Link tools that a USB-bootloader board does not have.
144
+ *
145
+ * Exported (pure) so the runner-selection contract is unit-testable without
146
+ * spawning west.
147
+ */
148
+ export function buildFlashArgs(
149
+ buildDir: string,
150
+ board: string,
151
+ userRunner: string | undefined,
152
+ port: string | undefined,
153
+ runnerArgs?: readonly string[],
154
+ ): string[] {
155
+ const args = ['flash', '-d', buildDir];
156
+ if (userRunner) {
157
+ args.push('--runner', userRunner);
158
+ }
159
+ if (port && board.startsWith('esp32')) {
160
+ args.push('--esp-device', port);
161
+ }
162
+ // Extra runner-specific flags, appended verbatim (west's runner parsers
163
+ // accept them after the runner is selected).
164
+ if (runnerArgs && runnerArgs.length > 0) {
165
+ args.push(...runnerArgs);
166
+ }
167
+ return args;
168
+ }
169
+
170
+ /**
171
+ * Classify a `west flash` result as success/failure.
172
+ *
173
+ * west's exit status is authoritative except for one known race in the uf2
174
+ * runner on Windows: the UF2 bootloader reboots to run new firmware the instant
175
+ * the file copy completes, unmounting the USB-MSC drive before `shutil.copy`'s
176
+ * trailing `copymode`/chmod runs. That raises `OSError: [WinError 433] A
177
+ * device which does not exist was specified` and makes west exit non-zero
178
+ * even though the firmware copied and flashed correctly (the LED blinks).
179
+ *
180
+ * The copy starting is logged ("Copying UF2 file to '<drive>'"); WinError 433
181
+ * during `copymode` after that point proves the data write finished and the
182
+ * drive only vanished on the metadata step. Treat that exact signature as
183
+ * success so the upload isn't reported as a failure. Genuine uf2 failures
184
+ * (no partition found, write errors before the copy) still surface as failures.
185
+ *
186
+ * Exported (pure) so the classification is unit-testable without spawning west.
187
+ */
188
+ export function classifyUploadResult(
189
+ runner: string | undefined,
190
+ status: number | null,
191
+ output: string,
192
+ ): boolean {
193
+ if (status === 0) return true;
194
+ if (isUf2DriveVanishRace(output)) return runner === 'uf2';
195
+ return false;
196
+ }
197
+
198
+ /**
199
+ * Whether `output` carries the benign UF2 copymode/WinError-433 race signature
200
+ * (see classifyUploadResult). Centralized so classify + cleanse share one match.
201
+ */
202
+ function isUf2DriveVanishRace(output: string): boolean {
203
+ return /Copying UF2 file to/.test(output)
204
+ && /WinError 433/.test(output)
205
+ && /copymode/.test(output);
206
+ }
207
+
208
+ /**
209
+ * Cleanse the `west flash` output shown to the user.
210
+ *
211
+ * When classifyUploadResult has decided a non-zero west exit was the benign UF2
212
+ * race (firmware copied, drive unmounted on the trailing chmod), the raw output
213
+ * is a wall of Python traceback that reads like a hard failure. Drop everything
214
+ * after the "Copying UF2 file to" line — i.e. the entire traceback — so a
215
+ * successful flash reads as a success (the framework's ✓ Done follows). Non-race
216
+ * output is returned untouched; genuine errors stay fully visible for diagnosis.
217
+ *
218
+ * Exported (pure) so the cleansing is unit-testable without spawning west.
219
+ */
220
+ export function cleanseUploadOutput(
221
+ runner: string | undefined,
222
+ status: number | null,
223
+ output: string,
224
+ ): string {
225
+ if (status === 0) return output;
226
+ if (runner === 'uf2' && isUf2DriveVanishRace(output)) {
227
+ // Keep everything west printed up to and including "Copying UF2 file to",
228
+ // then stop everything after that is the drive-vanish traceback.
229
+ const upto = output.match(/[\s\S]*Copying UF2 file to[^\n]*/);
230
+ const head = upto ? upto[0] : '-- west flash: using runner uf2';
231
+ return head;
232
+ }
233
+ return output;
234
+ }
235
+
236
+
237
+ /**
238
+ * Whether a failed `west build` output carries ninja's `dependency cycle`
239
+ * signature. Zephyr 4.3.99-dev snapshots have a regression
240
+ * (zephyrproject-rtos/zephyr#104757, fixed upstream by the #104784 revert,
241
+ * in v4.4+): after CMake re-runs from a .config change, the build dir's
242
+ * .ninja_deps records an `offsets.h -> offsets.c.obj -> offsets.h` cycle and
243
+ * ninja aborts with `ninja: error: dependency cycle: ...` before compiling
244
+ * anything. The cycle lives in the build dir, not the sources, so compile()
245
+ * recovers by deleting the dir and retrying once.
246
+ *
247
+ * Exported (pure) so the detection is unit-testable without spawning west.
248
+ */
249
+ export function isDependencyCycleFailure(output: string): boolean {
250
+ return output.includes('dependency cycle');
251
+ }
252
+
253
+ /** stdout+stderr of a spawnSync result coerced to one string. Defensive about
254
+ * the buffer form (spawnSync only returns strings when `encoding` is set,
255
+ * which every call site here does — but the coercion costs nothing). */
256
+ function combinedSpawnOutput(
257
+ result: { stdout?: string | Buffer | null; stderr?: string | Buffer | null },
258
+ ): string {
259
+ const so = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
260
+ const se = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
261
+ return so + se;
262
+ }
263
+
264
+ /**
265
+ * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
266
+ * The target board is carried via frameworkData.buildTarget; scaffolding
267
+ * happens at compile time when the target is known.
268
+ */
269
+ export const Toolchain = {
270
+ prepare(outputDir: string, entryPoint: string): void {
271
+ // Write the DT overlay for the default board (the real target is known at
272
+ // compile time; prepare runs before compile, so use the default board id).
273
+ // The overlay is additive and idempotent; compile re-runs prepare-equivalent
274
+ // logic in scaffold via the usage scan. Mirrors how Arduino's library
275
+ // resolution is a pre-build artifact step.
276
+ const projectRoot = basename(outputDir) === 'src' ? dirname(outputDir) : outputDir;
277
+ const board = DEFAULT_BOARD;
278
+ const chip = chipForBuild(projectRoot, board);
279
+ // Scan the emitted source for usage tokens (same authoritative signal the
280
+ // scaffold uses). entryPoint is the path to main.cpp; its dir is src/.
281
+ const srcDir = dirname(entryPoint);
282
+ let src = '';
283
+ try {
284
+ for (const name of readdirSync(srcDir)) {
285
+ if (name.endsWith('.cpp') || name.endsWith('.c')) {
286
+ src += readFileSync(join(srcDir, name), 'utf8');
287
+ }
288
+ }
289
+ } catch { /* src may not exist yet on first prepare */ }
290
+ const uses = (t: string): boolean => src.includes(t);
291
+ // Display usage tokens: the minimal GFX runtime (display_write/_fill_rect)
292
+ // and the UI display adapter (display_init / __tc_display_dev /
293
+ // DEVICE_DT_GET on the display nodelabel). Both paths need the DT overlay
294
+ // to enable the display node.
295
+ const usesDisplay = uses('display_write') || uses('display_init')
296
+ || uses('display_fill_rect') || uses('__tc_display_dev')
297
+ || uses('CuttlefishDisplayTarget');
298
+ // Both registered Zephyr display profiles use dtLabel 'display0', so the
299
+ // default profile's overlay block (&display0 { status="okay" }) is correct
300
+ // for either driver. Thread a non-default profile here only if a future
301
+ // board carries a display node under a different nodelabel.
302
+ const displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
303
+ // Touch controller kind comes from which DT nodelabel the emitted adapter
304
+ // references (FT6336U on I2C, XPT2046 on the display's SPI bus).
305
+ const usesTouch = uses('ft6336u') || uses('touch_');
306
+ const usesXpt = uses('xpt2046');
307
+ const overlay = generateOverlay(chip, {
308
+ usesI2c: uses('i2c_'),
309
+ usesSpi: uses('spi_'),
310
+ usesUart: uses('uart_'),
311
+ usesPwm: uses('pwm_'),
312
+ usesAdc: uses('adc_'),
313
+ adcReadPins: scanAdcReadPins(src, chip),
314
+ pwmUsedPins: scanPwmUsedPins(src, chip),
315
+ usesDisplay,
316
+ usesTouch: usesTouch || usesXpt,
317
+ touchController: usesXpt ? 'xpt2046' : 'ft6336u',
318
+ }, displayProfile);
319
+ const overlayDir = join(projectRoot, 'boards');
320
+ mkdirSync(overlayDir, { recursive: true });
321
+ writeIfChanged(join(overlayDir, `${board}.overlay`), overlay);
322
+ },
323
+
324
+ compile(o: ToolchainOptions): CompileResult {
325
+ const projectRoot = projectRootFromOptions(o);
326
+ const rawBoard = targetFromOptions(o);
327
+
328
+ // Fail fast on an incompatible Zephyr (clear message vs. a cryptic west/
329
+ // CMake board error), then normalize the board target for the installed
330
+ // version — Zephyr 4.3+ rejects bare multi-core board names, so a stale
331
+ // config (esp32s3_devkitc) is rewritten to the qualified form
332
+ // (esp32s3_devkitc/esp32s3/procpu). See toolchain/compat.ts.
333
+ const zephyrVersion = detectZephyrVersion();
334
+ const compat = checkZephyrCompat(zephyrVersion);
335
+ if (compat.status === 'out-of-range') {
336
+ throw new Error(
337
+ `Zephyr ${zephyrVersion} is outside the supported range (${compat.range}) for @typecad/framework-zephyr. ` +
338
+ `Set ZEPHYR_BASE to a compatible Zephyr checkout, or install one via '@typecad/zephyr-installer'.`,
339
+ );
340
+ }
341
+ if (compat.status === 'undetectable') {
342
+ console.warn(
343
+ `! Could not detect the installed Zephyr version (is ZEPHYR_BASE set?). ` +
344
+ `Skipping compat check; declared range is ${compat.range}.`,
345
+ );
346
+ }
347
+ const board = resolveBoardTarget(rawBoard, zephyrVersion);
348
+
349
+ const debugMode = new ZephyrStrategy().debugMode(board);
350
+ const isGdbDebug = o.debug === true && debugMode === 'gdb';
351
+ const zc = o.zephyrConfig as Record<string, unknown> | undefined;
352
+ const userKconfig = zc?.kconfig as Record<string, string> | undefined;
353
+ const configChanged = scaffoldZephyrProject(projectRoot, isGdbDebug, userKconfig, o.psram);
354
+
355
+ // Regenerate the DT overlay for the ACTUAL target board. prepare() writes
356
+ // it for the default board (the real target is unknown until compile), so
357
+ // the <default>.overlay it wrote does not match `west build -b <board>`.
358
+ // Zephyr auto-detects boards/<board>.overlay under APPLICATION_CONFIG_DIR.
359
+ try {
360
+ const chip = chipForBuild(projectRoot, board);
361
+ const srcDir = join(projectRoot, 'src');
362
+ let src = '';
363
+ try {
364
+ for (const name of readdirSync(srcDir)) {
365
+ if (name.endsWith('.cpp') || name.endsWith('.c')) {
366
+ src += readFileSync(join(srcDir, name), 'utf-8');
367
+ }
368
+ }
369
+ } catch { /* src may not exist */ }
370
+ const uses = (t: string): boolean => src.includes(t);
371
+ const usesDisplay = uses('display_write') || uses('display_init')
372
+ || uses('display_fill_rect') || uses('__tc_display_dev')
373
+ || uses('CuttlefishDisplayTarget');
374
+ // Derive the display dimensions from the emitted adapter code
375
+ // (display_width/height return the profile's w/h). This ensures the DT
376
+ // overlay's width/height match the panel the adapter targets, not the
377
+ // default profile — critical for drivers like ST7796S that initialize
378
+ // the panel geometry from the DT node.
379
+ let displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
380
+ if (usesDisplay) {
381
+ const wMatch = src.match(/display_width\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
382
+ const hMatch = src.match(/display_height\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
383
+ if (wMatch && hMatch) {
384
+ displayProfile = {
385
+ ...DEFAULT_ZEPHYR_DISPLAY_PROFILE,
386
+ width: parseInt(wMatch[1], 10),
387
+ height: parseInt(hMatch[1], 10),
388
+ };
389
+ }
390
+ }
391
+ // Extract display pin wiring (cs/dc/rst/spiFrequency/spiPins) from the
392
+ // config display section so the DT overlay wires the MIPI DBI bridge to
393
+ // the correct GPIOs + SPI bus pins.
394
+ const dispCfg = o.display as Record<string, unknown> | undefined;
395
+ const spiPins = (dispCfg?.spiPins ?? undefined) as
396
+ { sck?: unknown; mosi?: unknown; miso?: unknown } | undefined;
397
+ const wiring: DisplayWiring | undefined = dispCfg
398
+ ? {
399
+ cs: typeof dispCfg.cs === 'number' ? dispCfg.cs : undefined,
400
+ dc: typeof dispCfg.dc === 'number' ? dispCfg.dc : undefined,
401
+ rst: typeof dispCfg.rst === 'number' ? dispCfg.rst : undefined,
402
+ spiFrequency: typeof dispCfg.spiFrequency === 'number' ? dispCfg.spiFrequency : undefined,
403
+ sck: typeof spiPins?.sck === 'number' ? spiPins.sck : undefined,
404
+ mosi: typeof spiPins?.mosi === 'number' ? spiPins.mosi : undefined,
405
+ miso: typeof spiPins?.miso === 'number' ? spiPins.miso : undefined,
406
+ backlightPin: typeof dispCfg.backlightPin === 'number' ? dispCfg.backlightPin : undefined,
407
+ tearingEffectPin: typeof dispCfg.tearingEffectPin === 'number' ? dispCfg.tearingEffectPin : undefined,
408
+ }
409
+ : undefined;
410
+ // Extract touch pin wiring from the config display.touch section so the
411
+ // DT overlay wires the bus + touch node. I2C (FT6336U) carries
412
+ // irq/resetPin/sda/scl; SPI (XPT2046) carries irq/cs + the calibration
413
+ // range the xptek,xpt2046 binding requires.
414
+ const touchCfg = dispCfg?.touch as Record<string, unknown> | undefined;
415
+ const isXpt = touchCfg?.library === 'XPT2046_Touchscreen';
416
+ const touchCal = touchCfg?.calibration as
417
+ { xMin?: unknown; xMax?: unknown; yMin?: unknown; yMax?: unknown } | undefined;
418
+ const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined);
419
+ let touchWiring: TouchWiring | undefined = touchCfg
420
+ ? {
421
+ controller: isXpt ? 'xpt2046' : 'ft6336u',
422
+ irq: num(touchCfg.irq),
423
+ resetPin: num(touchCfg.resetPin),
424
+ sda: num(touchCfg.sda),
425
+ scl: num(touchCfg.scl),
426
+ cs: num(touchCfg.cs),
427
+ calibration: touchCal
428
+ ? {
429
+ xMin: num(touchCal.xMin) ?? 0,
430
+ xMax: num(touchCal.xMax) ?? 4095,
431
+ yMin: num(touchCal.yMin) ?? 0,
432
+ yMax: num(touchCal.yMax) ?? 4095,
433
+ }
434
+ : undefined,
435
+ minPressure: num(touchCfg.minPressure),
436
+ }
437
+ : undefined;
438
+ // Touch controller kind for Kconfig (bus driver selection) and the DT
439
+ // node shape: from the config when available, else from the DT nodelabel
440
+ // the emitted adapter references. Forced onto touchWiring so a source
441
+ // scan match without a config section still emits the right node.
442
+ const usesXpt = isXpt || uses('xpt2046');
443
+ if (usesXpt) {
444
+ touchWiring = { controller: 'xpt2046', ...(touchWiring ?? {}) };
445
+ }
446
+ const overlayDiagnostics: OverlayDiagnostic[] = [];
447
+ const overlay = generateOverlay(chip, {
448
+ usesI2c: uses('i2c_'),
449
+ usesSpi: uses('spi_'),
450
+ usesUart: uses('uart_'),
451
+ usesPwm: uses('pwm_'),
452
+ usesAdc: uses('adc_'),
453
+ adcReadPins: scanAdcReadPins(src, chip),
454
+ pwmUsedPins: scanPwmUsedPins(src, chip),
455
+ usesDisplay,
456
+ usesTouch: uses('ft6336u') || uses('touch_') || usesXpt,
457
+ touchController: usesXpt ? 'xpt2046' : 'ft6336u',
458
+ psram: o.psram,
459
+ }, displayProfile, wiring, touchWiring, overlayDiagnostics);
460
+ for (const d of overlayDiagnostics) {
461
+ console.warn(`overlay: ${d.message}`);
462
+ }
463
+ const overlayDir = join(projectRoot, 'boards');
464
+ mkdirSync(overlayDir, { recursive: true });
465
+ // Write the board-specific overlay (the one west loads). Zephyr looks for
466
+ // boards/<board_id>.overlay under APPLICATION_CONFIG_DIR use the bare
467
+ // board id (before any hardware-qualifier suffix, e.g. 'esp32_devkitc'
468
+ // not the full 'esp32_devkitc/esp32/procpu' target string). Library
469
+ // packages' overlay fragments are appended by the scaffold helper.
470
+ const boardId = board.split('/')[0];
471
+ writeIfChanged(
472
+ join(overlayDir, `${boardId}.overlay`),
473
+ appendLibraryOverlayFragments(overlay, projectRoot),
474
+ );
475
+ } catch { /* best-effort overlay regen; the build surfaces DT errors */ }
476
+
477
+ // Use a stable build dir so incremental builds reuse the Ninja graph.
478
+ // west defaults to <projectRoot>/build.
479
+ const buildDir = join(projectRoot, 'build');
480
+
481
+ // Reuse the build dir across builds so ninja recompiles only the changed
482
+ // app translation units and re-links — a pristine configure + the
483
+ // ~280-target Zephyr library rebuild costs minutes on Windows
484
+ // (demo-shadcn measures 69s of ninja wall time, 448s of summed compile
485
+ // work, and every build redid all of it). Nuke it only when the generated
486
+ // config changed (prj.conf / CMakeLists content), the one path that must
487
+ // not reuse a cached graph: Zephyr 4.3.99-dev snapshots carry a
488
+ // regression (zephyrproject-rtos/zephyr#104757, fixed by the #104784
489
+ // revert on 2026-03-03, in v4.4+) where re-running CMake after a .config
490
+ // change records an `offsets.h -> offsets.c.obj -> offsets.h` cycle in
491
+ // .ninja_deps, after which every ninja run fails with `dependency cycle`.
492
+ // Plain source edits never reconfigure CMake, so they cannot trigger it —
493
+ // and the retry after the spawn below self-heals any path that still does.
494
+ // Board switches need no nuke here: `west build` is --pristine=auto by
495
+ // default and recreates the dir itself when -b <board> mismatches the
496
+ // cached board.
497
+ if (configChanged) {
498
+ try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
499
+ }
500
+
501
+ const buildArgs = ['build', '-b', board, '-d', buildDir, projectRoot];
502
+ // Explicitly pass the generated DT overlay. Zephyr's auto-detection of
503
+ // boards/<board>.overlay fails for hardware-qualified targets (e.g.
504
+ // esp32_devkitc/esp32/procpu) because the FILE_SUFFIX matching doesn't
505
+ // resolve — passing -DDTC_OVERLAY_FILE forces it unconditionally.
506
+ const boardId = board.split('/')[0];
507
+ const overlayPath = join(projectRoot, 'boards', `${boardId}.overlay`);
508
+ try {
509
+ if (readFileSync(overlayPath, 'utf-8').length > 0) {
510
+ // CMake parses backslashes as escapes — use forward slashes so the
511
+ // Windows path survives the -D argument intact.
512
+ buildArgs.push('--', `-DDTC_OVERLAY_FILE=${overlayPath.replace(/\\/g, '/')}`);
513
+ }
514
+ } catch { /* no overlay — let Zephyr auto-detect or build without one */ }
515
+ // Append user cmake args from cuttlefish.config.ts zephyr.cmakeArgs.
516
+ const userCmakeArgs = zc?.cmakeArgs as string[] | undefined;
517
+ if (userCmakeArgs && userCmakeArgs.length > 0) {
518
+ if (!buildArgs.includes('--')) buildArgs.push('--');
519
+ buildArgs.push(...userCmakeArgs);
520
+ }
521
+ const inv = westSpawn(
522
+ buildArgs,
523
+ { cwd: projectRoot, encoding: 'utf-8', timeout: BUILD_TIMEOUT_MS },
524
+ );
525
+ let result = spawnSync(inv.command, inv.args, inv.options);
526
+ // Self-heal the Zephyr 4.3.99 dep-cycle regression (see the nuke comment
527
+ // above): when the cached .ninja_deps carries the cycle, ninja aborts with
528
+ // `dependency cycle` before compiling anything. The cycle lives in the
529
+ // build dir, not the sources one pristine retry clears it and the build
530
+ // proceeds. On fixed Zephyr (>=4.4) this never fires.
531
+ let pristineRetry = false;
532
+ if (result.status !== 0 && isDependencyCycleFailure(combinedSpawnOutput(result))) {
533
+ try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
534
+ result = spawnSync(inv.command, inv.args, inv.options);
535
+ pristineRetry = true;
536
+ }
537
+
538
+ const stdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
539
+ const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
540
+ const output = stdout + stderr + (pristineRetry
541
+ ? '\n[cuttlefish] dependency cycle detected in the cached build dir — retried with a pristine build'
542
+ : '');
543
+ // Prefix the build log with how west was resolved, for transparency.
544
+ const header = `Using west via ${inv.install.source}` +
545
+ (inv.install.zephyrBase ? ` (ZEPHYR_BASE=${inv.install.zephyrBase})` : '') + '\n';
546
+
547
+ // After a successful build in gdb mode (--debug on a probe-capable target),
548
+ // write the VS Code launch.json + tasks.json + gdb-script artifacts so F5
549
+ // attaches GDB to the chip's debug probe. Non-fatal on failure — a missing
550
+ // artifact doesn't block the build. Mirrors the deleted framework-esp32
551
+ // toolchain compile() debug-config wiring.
552
+ if (result.status === 0 && isGdbDebug) {
553
+ try {
554
+ const { workspaceRoot, sketchRel } = resolveDebugLocations(projectRoot);
555
+ writeDebugConfig({
556
+ projectRoot,
557
+ workspaceRoot,
558
+ sketchRel,
559
+ target: board,
560
+ buildDir,
561
+ sourceMapPath: join(dirname(o.sourcePath), `${basename(o.sourcePath)}.thcppmap.json`),
562
+ });
563
+ } catch (e) {
564
+ console.warn(`[cuttlefish] gdb debug config generation failed: ${(e as Error).message}`);
565
+ }
566
+ }
567
+
568
+ return {
569
+ success: result.status === 0,
570
+ output: header + output,
571
+ errors: parseCompileErrors(output, o.sourcePath),
572
+ };
573
+ },
574
+
575
+ upload(o: ToolchainOptions): UploadResult {
576
+ const projectRoot = projectRootFromOptions(o);
577
+ const buildDir = join(projectRoot, 'build');
578
+ const board = targetFromOptions(o);
579
+ const zc = o.zephyrConfig as Record<string, unknown> | undefined;
580
+ const runner = zc?.runner as string | undefined;
581
+ const runnerArgs = zc?.runnerArgs as string[] | undefined;
582
+ const args = buildFlashArgs(buildDir, board, runner, o.port, runnerArgs);
583
+
584
+ const inv = westSpawn(args, {
585
+ cwd: projectRoot,
586
+ encoding: 'utf-8',
587
+ timeout: FLASH_TIMEOUT_MS,
588
+ });
589
+ const result = spawnSync(inv.command, inv.args, inv.options);
590
+
591
+ const fstdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
592
+ const fstderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
593
+ const raw = fstdout + fstderr;
594
+ return {
595
+ success: classifyUploadResult(runner, result.status, raw),
596
+ output: cleanseUploadOutput(runner, result.status, raw),
597
+ };
598
+ },
599
+
600
+ monitor(o: ToolchainOptions): void {
601
+ // Serial monitor over USB-CDC. Zephyr does NOT ship a `west serial`
602
+ // subcommand (it's not a real west command — invoking it errors with
603
+ // "unknown command"). The discovered west install's venv carries pyserial,
604
+ // so run its bundled miniterm directly: `python -m serial.tools.miniterm`.
605
+ // That is the same cross-platform terminal pyserial provides in ESP-IDF's
606
+ // idf.py monitor, and it inherits stdio so Ctrl+C exits cleanly.
607
+ if (!o.port) {
608
+ throw new Error(
609
+ 'A serial port is required to monitor. Pass --port <COMx/ttyX>.',
610
+ );
611
+ }
612
+ // ESP32 USB-CDC console runs at 115200 (the Zephyr ESP32 board default).
613
+ // The CLI's generic default of 9600 is wrong for this target; honor an
614
+ // explicit --baud / config.console.baudRate when given, else 115200.
615
+ const baud = o.baud ?? 115200;
616
+ const install = discoverWest();
617
+ const py = install?.pythonExecutable ?? process.env.PYTHON ?? 'python';
618
+ // Reuse west-spawn's env builder (prepends the venv bin dir to PATH so the
619
+ // python we spawn resolves pyserial from the same venv). Falls back to the
620
+ // process env when no install is discovered.
621
+ const env = install ? buildEnv(install) : process.env;
622
+ spawnSync(py, ['-m', 'serial.tools.miniterm', o.port, String(baud)], {
623
+ cwd: projectRootFromOptions(o),
624
+ env,
625
+ stdio: 'inherit',
626
+ });
627
+ },
628
+
629
+ debug(o: ToolchainOptions): void {
630
+ // Launch an interactive GDB session for the last build. `west debug`
631
+ // auto-resolves the runner (openocd for esp32s3, nrfjprog/jlink for nRF)
632
+ // and the GDB binary from the build dir's CMakeCache/board.cmake — no
633
+ // hand-authored gdbinit needed. Inherits stdio so GDB runs interactively.
634
+ // (Not invoked by the standard build/compile flow; powers an explicit
635
+ // debug-attach entry point for terminal-driven debugging without VS Code.)
636
+ const projectRoot = projectRootFromOptions(o);
637
+ const buildDir = join(projectRoot, 'build');
638
+ const inv = westSpawn(['debug', '-d', buildDir], {
639
+ cwd: projectRoot,
640
+ encoding: 'utf-8',
641
+ stdio: 'inherit',
642
+ });
643
+ spawnSync(inv.command, inv.args, inv.options);
644
+ },
645
+ };