@typecad/framework-zephyr 1.0.0-alpha.10

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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/dist/async/timer-polyfill.d.ts +10 -0
  4. package/dist/async/timer-polyfill.js +95 -0
  5. package/dist/chips/controllers.d.ts +21 -0
  6. package/dist/chips/controllers.js +66 -0
  7. package/dist/chips/esp32.d.ts +2 -0
  8. package/dist/chips/esp32.js +59 -0
  9. package/dist/chips/esp32s3.d.ts +2 -0
  10. package/dist/chips/esp32s3.js +54 -0
  11. package/dist/chips/index.d.ts +15 -0
  12. package/dist/chips/index.js +45 -0
  13. package/dist/chips/resolve.d.ts +9 -0
  14. package/dist/chips/resolve.js +121 -0
  15. package/dist/chips/types.d.ts +154 -0
  16. package/dist/chips/types.js +14 -0
  17. package/dist/chips/xiao-ble.d.ts +2 -0
  18. package/dist/chips/xiao-ble.js +61 -0
  19. package/dist/debug-codegen.d.ts +33 -0
  20. package/dist/debug-codegen.js +162 -0
  21. package/dist/display/gfx.d.ts +17 -0
  22. package/dist/display/gfx.js +171 -0
  23. package/dist/display/index.d.ts +20 -0
  24. package/dist/display/index.js +53 -0
  25. package/dist/display/profiles.d.ts +25 -0
  26. package/dist/display/profiles.js +41 -0
  27. package/dist/display/touch-adapter.d.ts +9 -0
  28. package/dist/display/touch-adapter.js +95 -0
  29. package/dist/display/ui-adapter.d.ts +21 -0
  30. package/dist/display/ui-adapter.js +534 -0
  31. package/dist/doctor.d.ts +6 -0
  32. package/dist/doctor.js +48 -0
  33. package/dist/dt-config/kconfig.d.ts +27 -0
  34. package/dist/dt-config/kconfig.js +283 -0
  35. package/dist/dt-config/overlay.d.ts +35 -0
  36. package/dist/dt-config/overlay.js +246 -0
  37. package/dist/framework.manifest.d.ts +101 -0
  38. package/dist/framework.manifest.js +536 -0
  39. package/dist/index.d.ts +6 -0
  40. package/dist/index.js +17 -0
  41. package/dist/lowering/adc.d.ts +19 -0
  42. package/dist/lowering/adc.js +76 -0
  43. package/dist/lowering/ble.d.ts +20 -0
  44. package/dist/lowering/ble.js +419 -0
  45. package/dist/lowering/board.d.ts +9 -0
  46. package/dist/lowering/board.js +33 -0
  47. package/dist/lowering/gpio.d.ts +12 -0
  48. package/dist/lowering/gpio.js +123 -0
  49. package/dist/lowering/http.d.ts +23 -0
  50. package/dist/lowering/http.js +482 -0
  51. package/dist/lowering/i2c.d.ts +15 -0
  52. package/dist/lowering/i2c.js +114 -0
  53. package/dist/lowering/index.d.ts +29 -0
  54. package/dist/lowering/index.js +93 -0
  55. package/dist/lowering/interrupts.d.ts +21 -0
  56. package/dist/lowering/interrupts.js +91 -0
  57. package/dist/lowering/mqtt.d.ts +19 -0
  58. package/dist/lowering/mqtt.js +366 -0
  59. package/dist/lowering/power.d.ts +9 -0
  60. package/dist/lowering/power.js +64 -0
  61. package/dist/lowering/preferences.d.ts +23 -0
  62. package/dist/lowering/preferences.js +345 -0
  63. package/dist/lowering/pulse.d.ts +7 -0
  64. package/dist/lowering/pulse.js +82 -0
  65. package/dist/lowering/pwm.d.ts +15 -0
  66. package/dist/lowering/pwm.js +59 -0
  67. package/dist/lowering/random.d.ts +18 -0
  68. package/dist/lowering/random.js +129 -0
  69. package/dist/lowering/spi.d.ts +17 -0
  70. package/dist/lowering/spi.js +113 -0
  71. package/dist/lowering/timing.d.ts +9 -0
  72. package/dist/lowering/timing.js +63 -0
  73. package/dist/lowering/tone.d.ts +10 -0
  74. package/dist/lowering/tone.js +50 -0
  75. package/dist/lowering/uart.d.ts +15 -0
  76. package/dist/lowering/uart.js +102 -0
  77. package/dist/lowering/util.d.ts +4 -0
  78. package/dist/lowering/util.js +12 -0
  79. package/dist/lowering/wdt.d.ts +15 -0
  80. package/dist/lowering/wdt.js +80 -0
  81. package/dist/lowering/wifi.d.ts +21 -0
  82. package/dist/lowering/wifi.js +447 -0
  83. package/dist/lowering/worker-backing.d.ts +14 -0
  84. package/dist/lowering/worker-backing.js +79 -0
  85. package/dist/lowering/worker.d.ts +6 -0
  86. package/dist/lowering/worker.js +14 -0
  87. package/dist/strategy.d.ts +168 -0
  88. package/dist/strategy.js +1094 -0
  89. package/dist/toolchain/compat.d.ts +38 -0
  90. package/dist/toolchain/compat.js +133 -0
  91. package/dist/toolchain/debug-config.d.ts +82 -0
  92. package/dist/toolchain/debug-config.js +359 -0
  93. package/dist/toolchain/index.d.ts +73 -0
  94. package/dist/toolchain/index.js +444 -0
  95. package/dist/toolchain/scaffold.d.ts +15 -0
  96. package/dist/toolchain/scaffold.js +196 -0
  97. package/dist/toolchain/west-discover.d.ts +40 -0
  98. package/dist/toolchain/west-discover.js +203 -0
  99. package/dist/toolchain/west-spawn.d.ts +39 -0
  100. package/dist/toolchain/west-spawn.js +117 -0
  101. package/package.json +82 -0
  102. package/src/async/timer-polyfill.ts +107 -0
  103. package/src/chips/controllers.ts +74 -0
  104. package/src/chips/esp32.ts +62 -0
  105. package/src/chips/esp32s3.ts +57 -0
  106. package/src/chips/index.ts +54 -0
  107. package/src/chips/resolve.ts +146 -0
  108. package/src/chips/types.ts +163 -0
  109. package/src/chips/xiao-ble.ts +64 -0
  110. package/src/debug-codegen.ts +207 -0
  111. package/src/display/gfx.ts +190 -0
  112. package/src/display/index.ts +70 -0
  113. package/src/display/profiles.ts +63 -0
  114. package/src/display/touch-adapter.ts +100 -0
  115. package/src/display/ui-adapter.ts +558 -0
  116. package/src/doctor.ts +56 -0
  117. package/src/dt-config/kconfig.ts +308 -0
  118. package/src/dt-config/overlay.ts +293 -0
  119. package/src/framework.manifest.ts +558 -0
  120. package/src/index.ts +29 -0
  121. package/src/lowering/adc.ts +102 -0
  122. package/src/lowering/ble.ts +426 -0
  123. package/src/lowering/board.ts +38 -0
  124. package/src/lowering/gpio.ts +148 -0
  125. package/src/lowering/http.ts +488 -0
  126. package/src/lowering/i2c.ts +126 -0
  127. package/src/lowering/index.ts +86 -0
  128. package/src/lowering/interrupts.ts +114 -0
  129. package/src/lowering/mqtt.ts +371 -0
  130. package/src/lowering/power.ts +70 -0
  131. package/src/lowering/preferences.ts +354 -0
  132. package/src/lowering/pulse.ts +93 -0
  133. package/src/lowering/pwm.ts +74 -0
  134. package/src/lowering/random.ts +135 -0
  135. package/src/lowering/spi.ts +124 -0
  136. package/src/lowering/timing.ts +72 -0
  137. package/src/lowering/tone.ts +60 -0
  138. package/src/lowering/uart.ts +111 -0
  139. package/src/lowering/util.ts +12 -0
  140. package/src/lowering/wdt.ts +84 -0
  141. package/src/lowering/wifi.ts +452 -0
  142. package/src/lowering/worker-backing.ts +94 -0
  143. package/src/lowering/worker.ts +17 -0
  144. package/src/strategy.ts +1265 -0
  145. package/src/toolchain/compat.ts +145 -0
  146. package/src/toolchain/debug-config.ts +399 -0
  147. package/src/toolchain/index.ts +482 -0
  148. package/src/toolchain/scaffold.ts +196 -0
  149. package/src/toolchain/west-discover.ts +233 -0
  150. package/src/toolchain/west-spawn.ts +141 -0
@@ -0,0 +1,482 @@
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 } 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 } 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
+ * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
172
+ * The target board is carried via frameworkData.buildTarget; scaffolding
173
+ * happens at compile time when the target is known.
174
+ */
175
+ export const Toolchain = {
176
+ prepare(outputDir: string, entryPoint: string): void {
177
+ // Write the DT overlay for the default board (the real target is known at
178
+ // compile time; prepare runs before compile, so use the default board id).
179
+ // The overlay is additive and idempotent; compile re-runs prepare-equivalent
180
+ // logic in scaffold via the usage scan. Mirrors how Arduino's library
181
+ // resolution is a pre-build artifact step.
182
+ const projectRoot = basename(outputDir) === 'src' ? dirname(outputDir) : outputDir;
183
+ const board = DEFAULT_BOARD;
184
+ const chip = chipForTarget(board);
185
+ // Scan the emitted source for usage tokens (same authoritative signal the
186
+ // scaffold uses). entryPoint is the path to main.cpp; its dir is src/.
187
+ const srcDir = dirname(entryPoint);
188
+ let src = '';
189
+ try {
190
+ for (const name of readdirSync(srcDir)) {
191
+ if (name.endsWith('.cpp') || name.endsWith('.c')) {
192
+ src += readFileSync(join(srcDir, name), 'utf8');
193
+ }
194
+ }
195
+ } catch { /* src may not exist yet on first prepare */ }
196
+ const uses = (t: string): boolean => src.includes(t);
197
+ // Display usage tokens: the minimal GFX runtime (display_write/_fill_rect)
198
+ // and the UI display adapter (display_init / __tc_display_dev /
199
+ // DEVICE_DT_GET on the display nodelabel). Both paths need the DT overlay
200
+ // to enable the display node.
201
+ const usesDisplay = uses('display_write') || uses('display_init')
202
+ || uses('display_fill_rect') || uses('__tc_display_dev')
203
+ || uses('CuttlefishDisplayTarget');
204
+ // Both registered Zephyr display profiles use dtLabel 'display0', so the
205
+ // default profile's overlay block (&display0 { status="okay" }) is correct
206
+ // for either driver. Thread a non-default profile here only if a future
207
+ // board carries a display node under a different nodelabel.
208
+ const displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
209
+ const overlay = generateOverlay(chip, {
210
+ usesI2c: uses('i2c_'),
211
+ usesSpi: uses('spi_'),
212
+ usesUart: uses('uart_'),
213
+ usesDisplay,
214
+ usesTouch: uses('ft6336u') || uses('touch_'),
215
+ }, displayProfile);
216
+ const overlayDir = join(projectRoot, 'boards');
217
+ mkdirSync(overlayDir, { recursive: true });
218
+ writeIfChanged(join(overlayDir, `${board}.overlay`), overlay);
219
+ },
220
+
221
+ compile(o: ToolchainOptions): CompileResult {
222
+ const projectRoot = projectRootFromOptions(o);
223
+ const rawBoard = targetFromOptions(o);
224
+
225
+ // Fail fast on an incompatible Zephyr (clear message vs. a cryptic west/
226
+ // CMake board error), then normalize the board target for the installed
227
+ // version — Zephyr 4.3+ rejects bare multi-core board names, so a stale
228
+ // config (esp32s3_devkitc) is rewritten to the qualified form
229
+ // (esp32s3_devkitc/esp32s3/procpu). See toolchain/compat.ts.
230
+ const zephyrVersion = detectZephyrVersion();
231
+ const compat = checkZephyrCompat(zephyrVersion);
232
+ if (compat.status === 'out-of-range') {
233
+ throw new Error(
234
+ `Zephyr ${zephyrVersion} is outside the supported range (${compat.range}) for @typecad/framework-zephyr. ` +
235
+ `Set ZEPHYR_BASE to a compatible Zephyr checkout, or install one via '@typecad/zephyr-installer'.`,
236
+ );
237
+ }
238
+ if (compat.status === 'undetectable') {
239
+ console.warn(
240
+ `! Could not detect the installed Zephyr version (is ZEPHYR_BASE set?). ` +
241
+ `Skipping compat check; declared range is ${compat.range}.`,
242
+ );
243
+ }
244
+ const board = resolveBoardTarget(rawBoard, zephyrVersion);
245
+
246
+ const debugMode = new ZephyrStrategy().debugMode(board);
247
+ const isGdbDebug = o.debug === true && debugMode === 'gdb';
248
+ const zc = o.zephyrConfig as Record<string, unknown> | undefined;
249
+ const userKconfig = zc?.kconfig as Record<string, string> | undefined;
250
+ const configChanged = scaffoldZephyrProject(projectRoot, isGdbDebug, userKconfig, o.psram);
251
+
252
+ // Regenerate the DT overlay for the ACTUAL target board. prepare() writes
253
+ // it for the default board (the real target is unknown until compile), so
254
+ // the <default>.overlay it wrote does not match `west build -b <board>`.
255
+ // Zephyr auto-detects boards/<board>.overlay under APPLICATION_CONFIG_DIR.
256
+ try {
257
+ const chip = chipForTarget(board);
258
+ const srcDir = join(projectRoot, 'src');
259
+ let src = '';
260
+ try {
261
+ for (const name of readdirSync(srcDir)) {
262
+ if (name.endsWith('.cpp') || name.endsWith('.c')) {
263
+ src += readFileSync(join(srcDir, name), 'utf-8');
264
+ }
265
+ }
266
+ } catch { /* src may not exist */ }
267
+ const uses = (t: string): boolean => src.includes(t);
268
+ const usesDisplay = uses('display_write') || uses('display_init')
269
+ || uses('display_fill_rect') || uses('__tc_display_dev')
270
+ || uses('CuttlefishDisplayTarget');
271
+ // Derive the display dimensions from the emitted adapter code
272
+ // (display_width/height return the profile's w/h). This ensures the DT
273
+ // overlay's width/height match the panel the adapter targets, not the
274
+ // default profile — critical for drivers like ST7796S that initialize
275
+ // the panel geometry from the DT node.
276
+ let displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
277
+ if (usesDisplay) {
278
+ const wMatch = src.match(/display_width\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
279
+ const hMatch = src.match(/display_height\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
280
+ if (wMatch && hMatch) {
281
+ displayProfile = {
282
+ ...DEFAULT_ZEPHYR_DISPLAY_PROFILE,
283
+ width: parseInt(wMatch[1], 10),
284
+ height: parseInt(hMatch[1], 10),
285
+ };
286
+ }
287
+ }
288
+ // Extract display pin wiring (cs/dc/rst/spiFrequency/spiPins) from the
289
+ // config display section so the DT overlay wires the MIPI DBI bridge to
290
+ // the correct GPIOs + SPI bus pins.
291
+ const dispCfg = o.display as Record<string, unknown> | undefined;
292
+ const spiPins = (dispCfg?.spiPins ?? undefined) as
293
+ { sck?: unknown; mosi?: unknown; miso?: unknown } | undefined;
294
+ const wiring: DisplayWiring | undefined = dispCfg
295
+ ? {
296
+ cs: typeof dispCfg.cs === 'number' ? dispCfg.cs : undefined,
297
+ dc: typeof dispCfg.dc === 'number' ? dispCfg.dc : undefined,
298
+ rst: typeof dispCfg.rst === 'number' ? dispCfg.rst : undefined,
299
+ spiFrequency: typeof dispCfg.spiFrequency === 'number' ? dispCfg.spiFrequency : undefined,
300
+ sck: typeof spiPins?.sck === 'number' ? spiPins.sck : undefined,
301
+ mosi: typeof spiPins?.mosi === 'number' ? spiPins.mosi : undefined,
302
+ miso: typeof spiPins?.miso === 'number' ? spiPins.miso : undefined,
303
+ backlightPin: typeof dispCfg.backlightPin === 'number' ? dispCfg.backlightPin : undefined,
304
+ }
305
+ : undefined;
306
+ // Extract touch pin wiring (irq/resetPin/sda/scl) from the config
307
+ // display.touch section so the DT overlay wires the I2C bus + touch node.
308
+ const touchCfg = dispCfg?.touch as Record<string, unknown> | undefined;
309
+ const touchWiring: TouchWiring | undefined = touchCfg
310
+ ? {
311
+ irq: typeof touchCfg.irq === 'number' ? touchCfg.irq : undefined,
312
+ resetPin: typeof touchCfg.resetPin === 'number' ? touchCfg.resetPin : undefined,
313
+ sda: typeof touchCfg.sda === 'number' ? touchCfg.sda : undefined,
314
+ scl: typeof touchCfg.scl === 'number' ? touchCfg.scl : undefined,
315
+ }
316
+ : undefined;
317
+ const overlay = generateOverlay(chip, {
318
+ usesI2c: uses('i2c_'),
319
+ usesSpi: uses('spi_'),
320
+ usesUart: uses('uart_'),
321
+ usesDisplay,
322
+ usesTouch: uses('ft6336u') || uses('touch_'),
323
+ psram: o.psram,
324
+ }, displayProfile, wiring, touchWiring);
325
+ const overlayDir = join(projectRoot, 'boards');
326
+ mkdirSync(overlayDir, { recursive: true });
327
+ // Write the board-specific overlay (the one west loads). Zephyr looks for
328
+ // boards/<board_id>.overlay under APPLICATION_CONFIG_DIR — use the bare
329
+ // board id (before any hardware-qualifier suffix, e.g. 'esp32_devkitc'
330
+ // not the full 'esp32_devkitc/esp32/procpu' target string).
331
+ const boardId = board.split('/')[0];
332
+ writeIfChanged(join(overlayDir, `${boardId}.overlay`), overlay);
333
+ } catch { /* best-effort overlay regen; the build surfaces DT errors */ }
334
+
335
+ // Use a stable build dir so incremental builds reuse the Ninja graph.
336
+ // west defaults to <projectRoot>/build.
337
+ const buildDir = join(projectRoot, 'build');
338
+
339
+ // Nuke the build dir whenever a previous build exists. Zephyr's gen_offset
340
+ // flow (offsets.h is generated FROM offsets.c.obj, while gen_offset.h makes
341
+ // offsets.c include offsets.h) leaves a permanent `offsets.h ->
342
+ // offsets.c.obj -> offsets.h` cycle in the .ninja_deps log after the first
343
+ // incremental pass — ninja then fails every later build with `dependency
344
+ // cycle` even when nothing changed. This is a known Zephyr-on-Windows
345
+ // issue; the reliable fix is a pristine build dir per build. Also nukes
346
+ // when prj.conf/CMakeLists/overlay changed, so Kconfig symbols and
347
+ // generated headers never diverge from a cached graph.
348
+ if (configChanged || existsSync(join(buildDir, 'zephyr', 'zephyr.bin'))) {
349
+ try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
350
+ }
351
+
352
+ const buildArgs = ['build', '-b', board, '-d', buildDir, projectRoot];
353
+ // Explicitly pass the generated DT overlay. Zephyr's auto-detection of
354
+ // boards/<board>.overlay fails for hardware-qualified targets (e.g.
355
+ // esp32_devkitc/esp32/procpu) because the FILE_SUFFIX matching doesn't
356
+ // resolve — passing -DDTC_OVERLAY_FILE forces it unconditionally.
357
+ const boardId = board.split('/')[0];
358
+ const overlayPath = join(projectRoot, 'boards', `${boardId}.overlay`);
359
+ try {
360
+ if (readFileSync(overlayPath, 'utf-8').length > 0) {
361
+ // CMake parses backslashes as escapes — use forward slashes so the
362
+ // Windows path survives the -D argument intact.
363
+ buildArgs.push('--', `-DDTC_OVERLAY_FILE=${overlayPath.replace(/\\/g, '/')}`);
364
+ }
365
+ } catch { /* no overlay — let Zephyr auto-detect or build without one */ }
366
+ // Append user cmake args from cuttlefish.config.ts zephyr.cmakeArgs.
367
+ const userCmakeArgs = zc?.cmakeArgs as string[] | undefined;
368
+ if (userCmakeArgs && userCmakeArgs.length > 0) {
369
+ if (!buildArgs.includes('--')) buildArgs.push('--');
370
+ buildArgs.push(...userCmakeArgs);
371
+ }
372
+ const inv = westSpawn(
373
+ buildArgs,
374
+ { cwd: projectRoot, encoding: 'utf-8', timeout: BUILD_TIMEOUT_MS },
375
+ );
376
+ const result = spawnSync(inv.command, inv.args, inv.options);
377
+
378
+ const stdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
379
+ const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
380
+ const output = stdout + stderr;
381
+ // Prefix the build log with how west was resolved, for transparency.
382
+ const header = `Using west via ${inv.install.source}` +
383
+ (inv.install.zephyrBase ? ` (ZEPHYR_BASE=${inv.install.zephyrBase})` : '') + '\n';
384
+
385
+ // After a successful build in gdb mode (--debug on a probe-capable target),
386
+ // write the VS Code launch.json + tasks.json + gdb-script artifacts so F5
387
+ // attaches GDB to the chip's debug probe. Non-fatal on failure — a missing
388
+ // artifact doesn't block the build. Mirrors the deleted framework-esp32
389
+ // toolchain compile() debug-config wiring.
390
+ if (result.status === 0 && isGdbDebug) {
391
+ try {
392
+ const { workspaceRoot, sketchRel } = resolveDebugLocations(projectRoot);
393
+ writeDebugConfig({
394
+ projectRoot,
395
+ workspaceRoot,
396
+ sketchRel,
397
+ target: board,
398
+ buildDir,
399
+ sourceMapPath: join(dirname(o.sourcePath), `${basename(o.sourcePath)}.thcppmap.json`),
400
+ });
401
+ } catch (e) {
402
+ console.warn(`[cuttlefish] gdb debug config generation failed: ${(e as Error).message}`);
403
+ }
404
+ }
405
+
406
+ return {
407
+ success: result.status === 0,
408
+ output: header + output,
409
+ errors: parseCompileErrors(output, o.sourcePath),
410
+ };
411
+ },
412
+
413
+ upload(o: ToolchainOptions): UploadResult {
414
+ const projectRoot = projectRootFromOptions(o);
415
+ const buildDir = join(projectRoot, 'build');
416
+ const board = targetFromOptions(o);
417
+ const zc = o.zephyrConfig as Record<string, unknown> | undefined;
418
+ const runner = zc?.runner as string | undefined;
419
+ const args = buildFlashArgs(buildDir, board, runner, o.port);
420
+
421
+ const inv = westSpawn(args, {
422
+ cwd: projectRoot,
423
+ encoding: 'utf-8',
424
+ timeout: FLASH_TIMEOUT_MS,
425
+ });
426
+ const result = spawnSync(inv.command, inv.args, inv.options);
427
+
428
+ const fstdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
429
+ const fstderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
430
+ const raw = fstdout + fstderr;
431
+ return {
432
+ success: classifyUploadResult(runner, result.status, raw),
433
+ output: cleanseUploadOutput(runner, result.status, raw),
434
+ };
435
+ },
436
+
437
+ monitor(o: ToolchainOptions): void {
438
+ // Serial monitor over USB-CDC. Zephyr does NOT ship a `west serial`
439
+ // subcommand (it's not a real west command — invoking it errors with
440
+ // "unknown command"). The discovered west install's venv carries pyserial,
441
+ // so run its bundled miniterm directly: `python -m serial.tools.miniterm`.
442
+ // That is the same cross-platform terminal pyserial provides in ESP-IDF's
443
+ // idf.py monitor, and it inherits stdio so Ctrl+C exits cleanly.
444
+ if (!o.port) {
445
+ throw new Error(
446
+ 'A serial port is required to monitor. Pass --port <COMx/ttyX>.',
447
+ );
448
+ }
449
+ // ESP32 USB-CDC console runs at 115200 (the Zephyr ESP32 board default).
450
+ // The CLI's generic default of 9600 is wrong for this target; honor an
451
+ // explicit --baud / config.console.baudRate when given, else 115200.
452
+ const baud = o.baud ?? 115200;
453
+ const install = discoverWest();
454
+ const py = install?.pythonExecutable ?? process.env.PYTHON ?? 'python';
455
+ // Reuse west-spawn's env builder (prepends the venv bin dir to PATH so the
456
+ // python we spawn resolves pyserial from the same venv). Falls back to the
457
+ // process env when no install is discovered.
458
+ const env = install ? buildEnv(install) : process.env;
459
+ spawnSync(py, ['-m', 'serial.tools.miniterm', o.port, String(baud)], {
460
+ cwd: projectRootFromOptions(o),
461
+ env,
462
+ stdio: 'inherit',
463
+ });
464
+ },
465
+
466
+ debug(o: ToolchainOptions): void {
467
+ // Launch an interactive GDB session for the last build. `west debug`
468
+ // auto-resolves the runner (openocd for esp32s3, nrfjprog/jlink for nRF)
469
+ // and the GDB binary from the build dir's CMakeCache/board.cmake — no
470
+ // hand-authored gdbinit needed. Inherits stdio so GDB runs interactively.
471
+ // (Not invoked by the standard build/compile flow; powers an explicit
472
+ // debug-attach entry point for terminal-driven debugging without VS Code.)
473
+ const projectRoot = projectRootFromOptions(o);
474
+ const buildDir = join(projectRoot, 'build');
475
+ const inv = westSpawn(['debug', '-d', buildDir], {
476
+ cwd: projectRoot,
477
+ encoding: 'utf-8',
478
+ stdio: 'inherit',
479
+ });
480
+ spawnSync(inv.command, inv.args, inv.options);
481
+ },
482
+ };
@@ -0,0 +1,196 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Zephyr project scaffolding
3
+ //
4
+ // Writes the CMakeLists.txt + prj.conf around the cuttlefish-emitted src/main.cpp
5
+ // so `west build` has a valid Zephyr application. Idempotent: overwrites the
6
+ // generated files only when their content changes (avoids invalidating the
7
+ // Ninja incremental build's mtime-based dependency tracking).
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import { writeFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { resolveKconfigFragments, type KconfigUsage } from '../dt-config/kconfig.js';
13
+
14
+ /** Write a file only if the content differs from the existing file.
15
+ * Returns true when the file was written (content changed or file was new). */
16
+ export function writeIfChanged(filePath: string, content: string): boolean {
17
+ if (existsSync(filePath)) {
18
+ try {
19
+ if (readFileSync(filePath, 'utf8') === content) return false;
20
+ } catch {
21
+ // Read failed — fall through to write.
22
+ }
23
+ }
24
+ writeFileSync(filePath, content);
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Concatenate all emitted source under src/ so the scaffold can detect which
30
+ * peripherals the program actually uses. The cuttlefish lowering emits
31
+ * 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
34
+ * contract). Returns '' when no sources exist yet (first prepare call).
35
+ */
36
+ function readEmittedSources(srcDir: string): string {
37
+ if (!existsSync(srcDir)) return '';
38
+ 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
+ }
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * Emit the Zephyr application skeleton around the generated src/main.cpp.
53
+ *
54
+ * Layout written:
55
+ * <projectRoot>/
56
+ * CMakeLists.txt (find_package(Zephyr), target_sources app ← src/*.cpp)
57
+ * prj.conf (CONFIG_* for GPIO + C++ + libc)
58
+ * src/main.cpp (owned by cuttlefish's emit pipeline — NOT touched here)
59
+ *
60
+ * Idempotent. Mirrors scaffoldEspIdfProject's writeIfChanged discipline.
61
+ */
62
+ export function scaffoldZephyrProject(projectRoot: string, debug = false, userKconfig?: Record<string, string>, psram?: 'opi' | 'quad'): boolean {
63
+ const srcDir = join(projectRoot, 'src');
64
+ if (!existsSync(srcDir)) mkdirSync(srcDir, { recursive: true });
65
+
66
+ // Detect which peripherals the program actually uses by scanning the emitted
67
+ // source. The cuttlefish lowering emits well-known driver API tokens, so this
68
+ // is authoritative. Usage-gating the Kconfig symbols keeps a GPIO-only
69
+ // program from pulling in (and linking) stacks it doesn't need — notably
70
+ // NimBLE (CONFIG_BT), whose Espressif prebuilt blobs are not always present
71
+ // in a workspace. The symbol set itself lives in resolveKconfigFragments
72
+ // (dt-config/kconfig.ts), unit-tested separately.
73
+ const src = readEmittedSources(srcDir);
74
+ // Boundary-anchored token scan: a bare `src.includes('power_')` would match
75
+ // `tx_power_dbm` (emitted by the WiFi shim) and flip CONFIG_PM on for a
76
+ // WiFi-only program — on the ESP32-S3 that spins the PM soft-off retry loop
77
+ // forever and starves the app. Anchor each `<prefix>_` token at a leading
78
+ // word boundary so it matches the intended driver/HAL symbol, not a suffix.
79
+ const uses = (token: string): boolean => {
80
+ if (token.endsWith('_')) {
81
+ return new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(src);
82
+ }
83
+ return src.includes(token);
84
+ };
85
+ const usage: KconfigUsage = {
86
+ usesAdc: uses('adc_'),
87
+ usesPwm: uses('pwm_'),
88
+ usesI2c: uses('i2c_'),
89
+ usesSpi: uses('spi_'),
90
+ usesUart: uses('uart_'),
91
+ usesWdt: uses('wdt_'),
92
+ usesBle: uses('bt_') || uses('bt_gatt') || uses('bt_le_'),
93
+ usesDisplay: uses('display_write') || uses('display_init') || uses('display_fill_rect') || uses('__tc_display_dev') || uses('CuttlefishDisplayTarget'),
94
+ usesTouch: uses('ft6336u') || uses('touch_'),
95
+ // Power tokens: k_sleep + pm_state_force / PM_STATE_* (what power.ts emits).
96
+ // The old `power_` token matched nothing the power HAL emits and collides
97
+ // with tx_power_dbm — removed.
98
+ usesPower: uses('pm_') || uses('k_sleep') || uses('PM_STATE_'),
99
+ usesWifi: uses('wifi_') || uses('net_mgmt') || uses('conn_mgr'),
100
+ // HTTP: the __tc_http_* shim + http_client_req + getaddrinfo. The '_'-anchored
101
+ // 'http_' token matches __tc_http_* and http_client_req (the core HTTP lib
102
+ // symbol), mirroring how wifi_ detects the wifi shim. An http-only program
103
+ // still pulls the networking stack even without usesWifi.
104
+ usesHttp: uses('http_') || uses('__tc_http') || uses('http_client_req'),
105
+ // MQTT: the __tc_mqtt_* shim + mqtt_connect/mqtt_publish/mqtt_subscribe.
106
+ usesMqtt: uses('mqtt_') || uses('__tc_mqtt') || uses('mqtt_connect'),
107
+ // Preferences: the __tc_prefs_* shim + the settings_* API the shim calls.
108
+ // settings_load/save_one/delete + SETTINGS_STATIC_HANDLER_DEFINE all emit
109
+ // `settings_` symbols; __tc_prefs catches the typed accessors (put_int etc.
110
+ // template into __tc_prefs_put<...>, which keeps the __tc_prefs token).
111
+ usesPreferences: uses('settings_') || uses('__tc_prefs'),
112
+ // Random: the __tc_rand_* shim + the sys_rand_get entropy tap it seeds from.
113
+ usesRandom: uses('__tc_rand') || uses('sys_rand_get'),
114
+ psram,
115
+ };
116
+
117
+ let changed = false;
118
+
119
+ // ── Root CMakeLists.txt ─────────────────────────────────────────────────
120
+ // The canonical Zephyr CMake application. GLOB src/*.cpp so future multi-
121
+ // file emits are picked up automatically; cuttlefish owns the file contents.
122
+ const cmakeLists = [
123
+ '# Auto-generated by @typecad/framework-zephyr from cuttlefish.config.ts.',
124
+ 'cmake_minimum_required(VERSION 3.20.0)',
125
+ '',
126
+ 'find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})',
127
+ '',
128
+ 'project(zephyr_app)',
129
+ '',
130
+ '# Collect cuttlefish-emitted sources.',
131
+ 'file(GLOB app_sources src/*.cpp src/*.c)',
132
+ '',
133
+ 'target_sources(app PRIVATE ${app_sources})',
134
+ // When PSRAM is configured, define BOARD_HAS_PSRAM so the UI runtime's
135
+ // PSRAM canvas allocator (ui_create_canvas_best) is compiled in.
136
+ ...(psram ? ['', '# PSRAM enabled: activate the runtime PSRAM canvas paths.', 'target_compile_definitions(app PRIVATE BOARD_HAS_PSRAM)', ''] : ['']),
137
+ ].join('\n');
138
+ if (writeIfChanged(join(projectRoot, 'CMakeLists.txt'), cmakeLists)) changed = true;
139
+
140
+ // ── prj.conf ────────────────────────────────────────────────────────────
141
+ // Driver Kconfig symbols are usage-gated on the emitted source: only a
142
+ // peripheral whose driver API the program actually references is enabled.
143
+ // Zephyr's Kconfig treats an enabled-but-unused driver as harmless, BUT
144
+ // several driver stacks pull in large/specific dependencies — notably
145
+ // NimBLE (CONFIG_BT) needs Espressif prebuilt blobs that are not always
146
+ // fetched (`west blobs fetch hal_espressif`). Usage-gating keeps a GPIO-only
147
+ // blink from requiring those, and shrinks the link for every program. The
148
+ // core GPIO driver and C++ support stay unconditional. Symbol selection is
149
+ // delegated to resolveKconfigFragments (unit-tested in dt-config/kconfig).
150
+ const symbols = resolveKconfigFragments(usage, debug);
151
+
152
+ const prjConf: string[] = [
153
+ '# Auto-generated by @typecad/framework-zephyr from cuttlefish.config.ts.',
154
+ '# Edit in cuttlefish.config.ts (frameworkData), not here.',
155
+ '# Driver symbols are usage-gated on the emitted source — only peripherals',
156
+ '# the program references are enabled.',
157
+ '',
158
+ '# Route printf/stdout to the console UART (needed by the @typecad/expect',
159
+ '# test runner protocol, which uses printf via __tc_print/__tc_println).',
160
+ 'CONFIG_STDOUT_CONSOLE=y',
161
+ 'CONFIG_PRINTK=y',
162
+ '',
163
+ ];
164
+ // Emit a section header before the BT block when present.
165
+ let btHeaderEmitted = false;
166
+ let wifiHeaderEmitted = false;
167
+ for (const [sym, val] of symbols) {
168
+ if (sym === 'CONFIG_BT' && !btHeaderEmitted) {
169
+ prjConf.push('', '# Bluetooth (NimBLE peripheral).');
170
+ btHeaderEmitted = true;
171
+ }
172
+ if (sym === 'CONFIG_WIFI' && !wifiHeaderEmitted) {
173
+ prjConf.push('', '# WiFi / networking (conn_mgr + esp32 wifi driver).');
174
+ wifiHeaderEmitted = true;
175
+ }
176
+ // Skip auto-detected symbols that the user explicitly overrides
177
+ // in cuttlefish.config.ts zephyr.kconfig — the user value is
178
+ // emitted in the User Kconfig section below and takes precedence.
179
+ if (userKconfig && userKconfig.hasOwnProperty(sym)) continue;
180
+ prjConf.push(`${sym}=${val}`);
181
+ }
182
+ // Emit user-specified Kconfig from cuttlefish.config.ts zephyr.kconfig.
183
+ // These override any matching auto-detected symbol (skipped above).
184
+ if (userKconfig) {
185
+ prjConf.push('', '# User Kconfig (cuttlefish.config.ts → zephyr.kconfig).');
186
+ for (const [sym, val] of Object.entries(userKconfig)) {
187
+ prjConf.push(`${sym}=${val}`);
188
+ }
189
+ }
190
+ // CONFIG_BT_DEVICE_NAME is a string value not produced by the resolver — add
191
+ // it after the BT block when BLE is used (parity with the previous inline form).
192
+ if (usage.usesBle) prjConf.push('CONFIG_BT_DEVICE_NAME="TypeCAD"');
193
+ prjConf.push('');
194
+ if (writeIfChanged(join(projectRoot, 'prj.conf'), prjConf.join('\n'))) changed = true;
195
+ return changed;
196
+ }