@typecad/framework-zephyr 1.0.0-alpha.8

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 (144) 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/dt-config/kconfig.d.ts +27 -0
  32. package/dist/dt-config/kconfig.js +283 -0
  33. package/dist/dt-config/overlay.d.ts +35 -0
  34. package/dist/dt-config/overlay.js +246 -0
  35. package/dist/framework.manifest.d.ts +98 -0
  36. package/dist/framework.manifest.js +525 -0
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.js +12 -0
  39. package/dist/lowering/adc.d.ts +19 -0
  40. package/dist/lowering/adc.js +76 -0
  41. package/dist/lowering/ble.d.ts +20 -0
  42. package/dist/lowering/ble.js +419 -0
  43. package/dist/lowering/board.d.ts +9 -0
  44. package/dist/lowering/board.js +33 -0
  45. package/dist/lowering/gpio.d.ts +12 -0
  46. package/dist/lowering/gpio.js +123 -0
  47. package/dist/lowering/http.d.ts +23 -0
  48. package/dist/lowering/http.js +482 -0
  49. package/dist/lowering/i2c.d.ts +15 -0
  50. package/dist/lowering/i2c.js +114 -0
  51. package/dist/lowering/index.d.ts +29 -0
  52. package/dist/lowering/index.js +93 -0
  53. package/dist/lowering/interrupts.d.ts +21 -0
  54. package/dist/lowering/interrupts.js +91 -0
  55. package/dist/lowering/mqtt.d.ts +19 -0
  56. package/dist/lowering/mqtt.js +366 -0
  57. package/dist/lowering/power.d.ts +9 -0
  58. package/dist/lowering/power.js +64 -0
  59. package/dist/lowering/preferences.d.ts +23 -0
  60. package/dist/lowering/preferences.js +345 -0
  61. package/dist/lowering/pulse.d.ts +7 -0
  62. package/dist/lowering/pulse.js +82 -0
  63. package/dist/lowering/pwm.d.ts +15 -0
  64. package/dist/lowering/pwm.js +59 -0
  65. package/dist/lowering/random.d.ts +18 -0
  66. package/dist/lowering/random.js +129 -0
  67. package/dist/lowering/spi.d.ts +17 -0
  68. package/dist/lowering/spi.js +113 -0
  69. package/dist/lowering/timing.d.ts +9 -0
  70. package/dist/lowering/timing.js +63 -0
  71. package/dist/lowering/tone.d.ts +10 -0
  72. package/dist/lowering/tone.js +50 -0
  73. package/dist/lowering/uart.d.ts +15 -0
  74. package/dist/lowering/uart.js +102 -0
  75. package/dist/lowering/util.d.ts +4 -0
  76. package/dist/lowering/util.js +12 -0
  77. package/dist/lowering/wdt.d.ts +15 -0
  78. package/dist/lowering/wdt.js +80 -0
  79. package/dist/lowering/wifi.d.ts +21 -0
  80. package/dist/lowering/wifi.js +447 -0
  81. package/dist/lowering/worker-backing.d.ts +14 -0
  82. package/dist/lowering/worker-backing.js +79 -0
  83. package/dist/lowering/worker.d.ts +6 -0
  84. package/dist/lowering/worker.js +14 -0
  85. package/dist/strategy.d.ts +168 -0
  86. package/dist/strategy.js +1094 -0
  87. package/dist/toolchain/debug-config.d.ts +82 -0
  88. package/dist/toolchain/debug-config.js +359 -0
  89. package/dist/toolchain/index.d.ts +73 -0
  90. package/dist/toolchain/index.js +427 -0
  91. package/dist/toolchain/scaffold.d.ts +15 -0
  92. package/dist/toolchain/scaffold.js +196 -0
  93. package/dist/toolchain/west-discover.d.ts +40 -0
  94. package/dist/toolchain/west-discover.js +203 -0
  95. package/dist/toolchain/west-spawn.d.ts +39 -0
  96. package/dist/toolchain/west-spawn.js +117 -0
  97. package/package.json +82 -0
  98. package/src/async/timer-polyfill.ts +107 -0
  99. package/src/chips/controllers.ts +74 -0
  100. package/src/chips/esp32.ts +62 -0
  101. package/src/chips/esp32s3.ts +57 -0
  102. package/src/chips/index.ts +54 -0
  103. package/src/chips/resolve.ts +146 -0
  104. package/src/chips/types.ts +163 -0
  105. package/src/chips/xiao-ble.ts +64 -0
  106. package/src/debug-codegen.ts +207 -0
  107. package/src/display/gfx.ts +190 -0
  108. package/src/display/index.ts +70 -0
  109. package/src/display/profiles.ts +63 -0
  110. package/src/display/touch-adapter.ts +100 -0
  111. package/src/display/ui-adapter.ts +558 -0
  112. package/src/dt-config/kconfig.ts +308 -0
  113. package/src/dt-config/overlay.ts +293 -0
  114. package/src/framework.manifest.ts +545 -0
  115. package/src/index.ts +23 -0
  116. package/src/lowering/adc.ts +102 -0
  117. package/src/lowering/ble.ts +426 -0
  118. package/src/lowering/board.ts +38 -0
  119. package/src/lowering/gpio.ts +148 -0
  120. package/src/lowering/http.ts +488 -0
  121. package/src/lowering/i2c.ts +126 -0
  122. package/src/lowering/index.ts +86 -0
  123. package/src/lowering/interrupts.ts +114 -0
  124. package/src/lowering/mqtt.ts +371 -0
  125. package/src/lowering/power.ts +70 -0
  126. package/src/lowering/preferences.ts +354 -0
  127. package/src/lowering/pulse.ts +93 -0
  128. package/src/lowering/pwm.ts +74 -0
  129. package/src/lowering/random.ts +135 -0
  130. package/src/lowering/spi.ts +124 -0
  131. package/src/lowering/timing.ts +72 -0
  132. package/src/lowering/tone.ts +60 -0
  133. package/src/lowering/uart.ts +111 -0
  134. package/src/lowering/util.ts +12 -0
  135. package/src/lowering/wdt.ts +84 -0
  136. package/src/lowering/wifi.ts +452 -0
  137. package/src/lowering/worker-backing.ts +94 -0
  138. package/src/lowering/worker.ts +17 -0
  139. package/src/strategy.ts +1265 -0
  140. package/src/toolchain/debug-config.ts +399 -0
  141. package/src/toolchain/index.ts +459 -0
  142. package/src/toolchain/scaffold.ts +196 -0
  143. package/src/toolchain/west-discover.ts +233 -0
  144. package/src/toolchain/west-spawn.ts +141 -0
@@ -0,0 +1,427 @@
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
+ import { spawnSync } from 'node:child_process';
21
+ import { basename, dirname, join } from 'node:path';
22
+ import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
23
+ import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
24
+ import { scaffoldZephyrProject, writeIfChanged } from './scaffold.js';
25
+ import { westSpawn, buildEnv } from './west-spawn.js';
26
+ import { discoverWest } from './west-discover.js';
27
+ import { writeDebugConfig, resolveDebugLocations } from './debug-config.js';
28
+ import { ZephyrStrategy } from '../strategy.js';
29
+ import { generateOverlay } from '../dt-config/overlay.js';
30
+ import { chipForTarget } from '../chips/index.js';
31
+ import { DEFAULT_ZEPHYR_DISPLAY_PROFILE } from '../display/profiles.js';
32
+ /** Default board target — the framework's MVP canonical board. */
33
+ const DEFAULT_BOARD = 'xiao_ble';
34
+ function targetFromOptions(o) {
35
+ // The cuttlefish CLI populates ToolchainOptions.buildTarget from
36
+ // config.frameworkData.buildTarget. Accept frameworkData.target as an alias.
37
+ const fcTarget = o.frameworkConfig?.target;
38
+ return o.buildTarget ?? fcTarget ?? DEFAULT_BOARD;
39
+ }
40
+ /**
41
+ * Derive the Zephyr project root from the cuttlefish-emitted source path.
42
+ *
43
+ * Cuttlefish emits `src/main.cpp` under the output dir. The CLI passes
44
+ * `sourcePath` = full path to `main.cpp` and `outputDir` = its parent (`src/`).
45
+ * For Zephyr, the project root is the parent of `src/` — one level above
46
+ * `outputDir`. Detect that shape and adjust; otherwise fall back to `outputDir`.
47
+ */
48
+ export function projectRootFromOptions(o) {
49
+ const outDir = o.outputDir;
50
+ if (basename(outDir) === 'src') {
51
+ return dirname(outDir);
52
+ }
53
+ return outDir;
54
+ }
55
+ /**
56
+ * west build timeout. Zephyr's first build fetches the toolchain modules and
57
+ * configures CMake/Ninja, which can take several minutes; allow generous headroom.
58
+ */
59
+ const BUILD_TIMEOUT_MS = 600_000;
60
+ const FLASH_TIMEOUT_MS = 120_000;
61
+ /**
62
+ * Build the `west flash` argument list for a board.
63
+ *
64
+ * Runner selection: each board's board.cmake declares a sensible default flash
65
+ * runner for its hardware (xiao_ble → nrfutil, esp32* → esptool), and `west
66
+ * flash` resolves it automatically. The framework only intervenes where the
67
+ * board default needs an argument it can't infer:
68
+ * - An explicit `zephyr.runner` (from cuttlefish.config.ts) always wins.
69
+ * - ESP32 boards forward the port via `--esp-device` (esptool reads the
70
+ * device from it); board.cmake still picks the runner.
71
+ * - Every other board trusts the board.cmake default. Previously this forced
72
+ * `--runner nrfjprog` for every non-ESP32 target, which broke boards whose
73
+ * default is not nrfjprog (xiao_ble defaults to nrfutil) and required
74
+ * Nordic J-Link tools that a USB-bootloader board does not have.
75
+ *
76
+ * Exported (pure) so the runner-selection contract is unit-testable without
77
+ * spawning west.
78
+ */
79
+ export function buildFlashArgs(buildDir, board, userRunner, port) {
80
+ const args = ['flash', '-d', buildDir];
81
+ if (userRunner) {
82
+ args.push('--runner', userRunner);
83
+ }
84
+ if (port && board.startsWith('esp32')) {
85
+ args.push('--esp-device', port);
86
+ }
87
+ return args;
88
+ }
89
+ /**
90
+ * Classify a `west flash` result as success/failure.
91
+ *
92
+ * west's exit status is authoritative except for one known race in the uf2
93
+ * runner on Windows: the UF2 bootloader reboots to run new firmware the instant
94
+ * the file copy completes, unmounting the USB-MSC drive before `shutil.copy`'s
95
+ * trailing `copymode`/chmod runs. That raises `OSError: [WinError 433] A
96
+ * device which does not exist was specified` and makes west exit non-zero —
97
+ * even though the firmware copied and flashed correctly (the LED blinks).
98
+ *
99
+ * The copy starting is logged ("Copying UF2 file to '<drive>'"); WinError 433
100
+ * during `copymode` after that point proves the data write finished and the
101
+ * drive only vanished on the metadata step. Treat that exact signature as
102
+ * success so the upload isn't reported as a failure. Genuine uf2 failures
103
+ * (no partition found, write errors before the copy) still surface as failures.
104
+ *
105
+ * Exported (pure) so the classification is unit-testable without spawning west.
106
+ */
107
+ export function classifyUploadResult(runner, status, output) {
108
+ if (status === 0)
109
+ return true;
110
+ if (isUf2DriveVanishRace(output))
111
+ return runner === 'uf2';
112
+ return false;
113
+ }
114
+ /**
115
+ * Whether `output` carries the benign UF2 copymode/WinError-433 race signature
116
+ * (see classifyUploadResult). Centralized so classify + cleanse share one match.
117
+ */
118
+ function isUf2DriveVanishRace(output) {
119
+ return /Copying UF2 file to/.test(output)
120
+ && /WinError 433/.test(output)
121
+ && /copymode/.test(output);
122
+ }
123
+ /**
124
+ * Cleanse the `west flash` output shown to the user.
125
+ *
126
+ * When classifyUploadResult has decided a non-zero west exit was the benign UF2
127
+ * race (firmware copied, drive unmounted on the trailing chmod), the raw output
128
+ * is a wall of Python traceback that reads like a hard failure. Drop everything
129
+ * after the "Copying UF2 file to" line — i.e. the entire traceback — so a
130
+ * successful flash reads as a success (the framework's ✓ Done follows). Non-race
131
+ * output is returned untouched; genuine errors stay fully visible for diagnosis.
132
+ *
133
+ * Exported (pure) so the cleansing is unit-testable without spawning west.
134
+ */
135
+ export function cleanseUploadOutput(runner, status, output) {
136
+ if (status === 0)
137
+ return output;
138
+ if (runner === 'uf2' && isUf2DriveVanishRace(output)) {
139
+ // Keep everything west printed up to and including "Copying UF2 file to",
140
+ // then stop — everything after that is the drive-vanish traceback.
141
+ const upto = output.match(/[\s\S]*Copying UF2 file to[^\n]*/);
142
+ const head = upto ? upto[0] : '-- west flash: using runner uf2';
143
+ return head;
144
+ }
145
+ return output;
146
+ }
147
+ /**
148
+ * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
149
+ * The target board is carried via frameworkData.buildTarget; scaffolding
150
+ * happens at compile time when the target is known.
151
+ */
152
+ export const Toolchain = {
153
+ prepare(outputDir, entryPoint) {
154
+ // Write the DT overlay for the default board (the real target is known at
155
+ // compile time; prepare runs before compile, so use the default board id).
156
+ // The overlay is additive and idempotent; compile re-runs prepare-equivalent
157
+ // logic in scaffold via the usage scan. Mirrors how Arduino's library
158
+ // resolution is a pre-build artifact step.
159
+ const projectRoot = basename(outputDir) === 'src' ? dirname(outputDir) : outputDir;
160
+ const board = DEFAULT_BOARD;
161
+ const chip = chipForTarget(board);
162
+ // Scan the emitted source for usage tokens (same authoritative signal the
163
+ // scaffold uses). entryPoint is the path to main.cpp; its dir is src/.
164
+ const srcDir = dirname(entryPoint);
165
+ let src = '';
166
+ try {
167
+ for (const name of readdirSync(srcDir)) {
168
+ if (name.endsWith('.cpp') || name.endsWith('.c')) {
169
+ src += readFileSync(join(srcDir, name), 'utf8');
170
+ }
171
+ }
172
+ }
173
+ catch { /* src may not exist yet on first prepare */ }
174
+ const uses = (t) => src.includes(t);
175
+ // Display usage tokens: the minimal GFX runtime (display_write/_fill_rect)
176
+ // and the UI display adapter (display_init / __tc_display_dev /
177
+ // DEVICE_DT_GET on the display nodelabel). Both paths need the DT overlay
178
+ // to enable the display node.
179
+ const usesDisplay = uses('display_write') || uses('display_init')
180
+ || uses('display_fill_rect') || uses('__tc_display_dev')
181
+ || uses('CuttlefishDisplayTarget');
182
+ // Both registered Zephyr display profiles use dtLabel 'display0', so the
183
+ // default profile's overlay block (&display0 { status="okay" }) is correct
184
+ // for either driver. Thread a non-default profile here only if a future
185
+ // board carries a display node under a different nodelabel.
186
+ const displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
187
+ const overlay = generateOverlay(chip, {
188
+ usesI2c: uses('i2c_'),
189
+ usesSpi: uses('spi_'),
190
+ usesUart: uses('uart_'),
191
+ usesDisplay,
192
+ usesTouch: uses('ft6336u') || uses('touch_'),
193
+ }, displayProfile);
194
+ const overlayDir = join(projectRoot, 'boards');
195
+ mkdirSync(overlayDir, { recursive: true });
196
+ writeIfChanged(join(overlayDir, `${board}.overlay`), overlay);
197
+ },
198
+ compile(o) {
199
+ const projectRoot = projectRootFromOptions(o);
200
+ const board = targetFromOptions(o);
201
+ const debugMode = new ZephyrStrategy().debugMode(board);
202
+ const isGdbDebug = o.debug === true && debugMode === 'gdb';
203
+ const zc = o.zephyrConfig;
204
+ const userKconfig = zc?.kconfig;
205
+ const configChanged = scaffoldZephyrProject(projectRoot, isGdbDebug, userKconfig, o.psram);
206
+ // Regenerate the DT overlay for the ACTUAL target board. prepare() writes
207
+ // it for the default board (the real target is unknown until compile), so
208
+ // the <default>.overlay it wrote does not match `west build -b <board>`.
209
+ // Zephyr auto-detects boards/<board>.overlay under APPLICATION_CONFIG_DIR.
210
+ try {
211
+ const chip = chipForTarget(board);
212
+ const srcDir = join(projectRoot, 'src');
213
+ let src = '';
214
+ try {
215
+ for (const name of readdirSync(srcDir)) {
216
+ if (name.endsWith('.cpp') || name.endsWith('.c')) {
217
+ src += readFileSync(join(srcDir, name), 'utf-8');
218
+ }
219
+ }
220
+ }
221
+ catch { /* src may not exist */ }
222
+ const uses = (t) => src.includes(t);
223
+ const usesDisplay = uses('display_write') || uses('display_init')
224
+ || uses('display_fill_rect') || uses('__tc_display_dev')
225
+ || uses('CuttlefishDisplayTarget');
226
+ // Derive the display dimensions from the emitted adapter code
227
+ // (display_width/height return the profile's w/h). This ensures the DT
228
+ // overlay's width/height match the panel the adapter targets, not the
229
+ // default profile — critical for drivers like ST7796S that initialize
230
+ // the panel geometry from the DT node.
231
+ let displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
232
+ if (usesDisplay) {
233
+ const wMatch = src.match(/display_width\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
234
+ const hMatch = src.match(/display_height\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
235
+ if (wMatch && hMatch) {
236
+ displayProfile = {
237
+ ...DEFAULT_ZEPHYR_DISPLAY_PROFILE,
238
+ width: parseInt(wMatch[1], 10),
239
+ height: parseInt(hMatch[1], 10),
240
+ };
241
+ }
242
+ }
243
+ // Extract display pin wiring (cs/dc/rst/spiFrequency/spiPins) from the
244
+ // config display section so the DT overlay wires the MIPI DBI bridge to
245
+ // the correct GPIOs + SPI bus pins.
246
+ const dispCfg = o.display;
247
+ const spiPins = (dispCfg?.spiPins ?? undefined);
248
+ const wiring = dispCfg
249
+ ? {
250
+ cs: typeof dispCfg.cs === 'number' ? dispCfg.cs : undefined,
251
+ dc: typeof dispCfg.dc === 'number' ? dispCfg.dc : undefined,
252
+ rst: typeof dispCfg.rst === 'number' ? dispCfg.rst : undefined,
253
+ spiFrequency: typeof dispCfg.spiFrequency === 'number' ? dispCfg.spiFrequency : undefined,
254
+ sck: typeof spiPins?.sck === 'number' ? spiPins.sck : undefined,
255
+ mosi: typeof spiPins?.mosi === 'number' ? spiPins.mosi : undefined,
256
+ miso: typeof spiPins?.miso === 'number' ? spiPins.miso : undefined,
257
+ backlightPin: typeof dispCfg.backlightPin === 'number' ? dispCfg.backlightPin : undefined,
258
+ }
259
+ : undefined;
260
+ // Extract touch pin wiring (irq/resetPin/sda/scl) from the config
261
+ // display.touch section so the DT overlay wires the I2C bus + touch node.
262
+ const touchCfg = dispCfg?.touch;
263
+ const touchWiring = touchCfg
264
+ ? {
265
+ irq: typeof touchCfg.irq === 'number' ? touchCfg.irq : undefined,
266
+ resetPin: typeof touchCfg.resetPin === 'number' ? touchCfg.resetPin : undefined,
267
+ sda: typeof touchCfg.sda === 'number' ? touchCfg.sda : undefined,
268
+ scl: typeof touchCfg.scl === 'number' ? touchCfg.scl : undefined,
269
+ }
270
+ : undefined;
271
+ const overlay = generateOverlay(chip, {
272
+ usesI2c: uses('i2c_'),
273
+ usesSpi: uses('spi_'),
274
+ usesUart: uses('uart_'),
275
+ usesDisplay,
276
+ usesTouch: uses('ft6336u') || uses('touch_'),
277
+ psram: o.psram,
278
+ }, displayProfile, wiring, touchWiring);
279
+ const overlayDir = join(projectRoot, 'boards');
280
+ mkdirSync(overlayDir, { recursive: true });
281
+ // Write the board-specific overlay (the one west loads). Zephyr looks for
282
+ // boards/<board_id>.overlay under APPLICATION_CONFIG_DIR — use the bare
283
+ // board id (before any hardware-qualifier suffix, e.g. 'esp32_devkitc'
284
+ // not the full 'esp32_devkitc/esp32/procpu' target string).
285
+ const boardId = board.split('/')[0];
286
+ writeIfChanged(join(overlayDir, `${boardId}.overlay`), overlay);
287
+ }
288
+ catch { /* best-effort overlay regen; the build surfaces DT errors */ }
289
+ // Use a stable build dir so incremental builds reuse the Ninja graph.
290
+ // west defaults to <projectRoot>/build.
291
+ const buildDir = join(projectRoot, 'build');
292
+ // Nuke the build dir whenever a previous build exists. Zephyr's gen_offset
293
+ // flow (offsets.h is generated FROM offsets.c.obj, while gen_offset.h makes
294
+ // offsets.c include offsets.h) leaves a permanent `offsets.h ->
295
+ // offsets.c.obj -> offsets.h` cycle in the .ninja_deps log after the first
296
+ // incremental pass — ninja then fails every later build with `dependency
297
+ // cycle` even when nothing changed. This is a known Zephyr-on-Windows
298
+ // issue; the reliable fix is a pristine build dir per build. Also nukes
299
+ // when prj.conf/CMakeLists/overlay changed, so Kconfig symbols and
300
+ // generated headers never diverge from a cached graph.
301
+ if (configChanged || existsSync(join(buildDir, 'zephyr', 'zephyr.bin'))) {
302
+ try {
303
+ rmSync(buildDir, { recursive: true, force: true });
304
+ }
305
+ catch { /* may not exist */ }
306
+ }
307
+ const buildArgs = ['build', '-b', board, '-d', buildDir, projectRoot];
308
+ // Explicitly pass the generated DT overlay. Zephyr's auto-detection of
309
+ // boards/<board>.overlay fails for hardware-qualified targets (e.g.
310
+ // esp32_devkitc/esp32/procpu) because the FILE_SUFFIX matching doesn't
311
+ // resolve — passing -DDTC_OVERLAY_FILE forces it unconditionally.
312
+ const boardId = board.split('/')[0];
313
+ const overlayPath = join(projectRoot, 'boards', `${boardId}.overlay`);
314
+ try {
315
+ if (readFileSync(overlayPath, 'utf-8').length > 0) {
316
+ // CMake parses backslashes as escapes — use forward slashes so the
317
+ // Windows path survives the -D argument intact.
318
+ buildArgs.push('--', `-DDTC_OVERLAY_FILE=${overlayPath.replace(/\\/g, '/')}`);
319
+ }
320
+ }
321
+ catch { /* no overlay — let Zephyr auto-detect or build without one */ }
322
+ // Append user cmake args from cuttlefish.config.ts zephyr.cmakeArgs.
323
+ const userCmakeArgs = zc?.cmakeArgs;
324
+ if (userCmakeArgs && userCmakeArgs.length > 0) {
325
+ if (!buildArgs.includes('--'))
326
+ buildArgs.push('--');
327
+ buildArgs.push(...userCmakeArgs);
328
+ }
329
+ const inv = westSpawn(buildArgs, { cwd: projectRoot, encoding: 'utf-8', timeout: BUILD_TIMEOUT_MS });
330
+ const result = spawnSync(inv.command, inv.args, inv.options);
331
+ const stdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
332
+ const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
333
+ const output = stdout + stderr;
334
+ // Prefix the build log with how west was resolved, for transparency.
335
+ const header = `Using west via ${inv.install.source}` +
336
+ (inv.install.zephyrBase ? ` (ZEPHYR_BASE=${inv.install.zephyrBase})` : '') + '\n';
337
+ // After a successful build in gdb mode (--debug on a probe-capable target),
338
+ // write the VS Code launch.json + tasks.json + gdb-script artifacts so F5
339
+ // attaches GDB to the chip's debug probe. Non-fatal on failure — a missing
340
+ // artifact doesn't block the build. Mirrors the deleted framework-esp32
341
+ // toolchain compile() debug-config wiring.
342
+ if (result.status === 0 && isGdbDebug) {
343
+ try {
344
+ const { workspaceRoot, sketchRel } = resolveDebugLocations(projectRoot);
345
+ writeDebugConfig({
346
+ projectRoot,
347
+ workspaceRoot,
348
+ sketchRel,
349
+ target: board,
350
+ buildDir,
351
+ sourceMapPath: join(dirname(o.sourcePath), `${basename(o.sourcePath)}.thcppmap.json`),
352
+ });
353
+ }
354
+ catch (e) {
355
+ console.warn(`[cuttlefish] gdb debug config generation failed: ${e.message}`);
356
+ }
357
+ }
358
+ return {
359
+ success: result.status === 0,
360
+ output: header + output,
361
+ errors: parseCompileErrors(output, o.sourcePath),
362
+ };
363
+ },
364
+ upload(o) {
365
+ const projectRoot = projectRootFromOptions(o);
366
+ const buildDir = join(projectRoot, 'build');
367
+ const board = targetFromOptions(o);
368
+ const zc = o.zephyrConfig;
369
+ const runner = zc?.runner;
370
+ const args = buildFlashArgs(buildDir, board, runner, o.port);
371
+ const inv = westSpawn(args, {
372
+ cwd: projectRoot,
373
+ encoding: 'utf-8',
374
+ timeout: FLASH_TIMEOUT_MS,
375
+ });
376
+ const result = spawnSync(inv.command, inv.args, inv.options);
377
+ const fstdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
378
+ const fstderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
379
+ const raw = fstdout + fstderr;
380
+ return {
381
+ success: classifyUploadResult(runner, result.status, raw),
382
+ output: cleanseUploadOutput(runner, result.status, raw),
383
+ };
384
+ },
385
+ monitor(o) {
386
+ // Serial monitor over USB-CDC. Zephyr does NOT ship a `west serial`
387
+ // subcommand (it's not a real west command — invoking it errors with
388
+ // "unknown command"). The discovered west install's venv carries pyserial,
389
+ // so run its bundled miniterm directly: `python -m serial.tools.miniterm`.
390
+ // That is the same cross-platform terminal pyserial provides in ESP-IDF's
391
+ // idf.py monitor, and it inherits stdio so Ctrl+C exits cleanly.
392
+ if (!o.port) {
393
+ throw new Error('A serial port is required to monitor. Pass --port <COMx/ttyX>.');
394
+ }
395
+ // ESP32 USB-CDC console runs at 115200 (the Zephyr ESP32 board default).
396
+ // The CLI's generic default of 9600 is wrong for this target; honor an
397
+ // explicit --baud / config.console.baudRate when given, else 115200.
398
+ const baud = o.baud ?? 115200;
399
+ const install = discoverWest();
400
+ const py = install?.pythonExecutable ?? process.env.PYTHON ?? 'python';
401
+ // Reuse west-spawn's env builder (prepends the venv bin dir to PATH so the
402
+ // python we spawn resolves pyserial from the same venv). Falls back to the
403
+ // process env when no install is discovered.
404
+ const env = install ? buildEnv(install) : process.env;
405
+ spawnSync(py, ['-m', 'serial.tools.miniterm', o.port, String(baud)], {
406
+ cwd: projectRootFromOptions(o),
407
+ env,
408
+ stdio: 'inherit',
409
+ });
410
+ },
411
+ debug(o) {
412
+ // Launch an interactive GDB session for the last build. `west debug`
413
+ // auto-resolves the runner (openocd for esp32s3, nrfjprog/jlink for nRF)
414
+ // and the GDB binary from the build dir's CMakeCache/board.cmake — no
415
+ // hand-authored gdbinit needed. Inherits stdio so GDB runs interactively.
416
+ // (Not invoked by the standard build/compile flow; powers an explicit
417
+ // debug-attach entry point for terminal-driven debugging without VS Code.)
418
+ const projectRoot = projectRootFromOptions(o);
419
+ const buildDir = join(projectRoot, 'build');
420
+ const inv = westSpawn(['debug', '-d', buildDir], {
421
+ cwd: projectRoot,
422
+ encoding: 'utf-8',
423
+ stdio: 'inherit',
424
+ });
425
+ spawnSync(inv.command, inv.args, inv.options);
426
+ },
427
+ };
@@ -0,0 +1,15 @@
1
+ /** Write a file only if the content differs from the existing file.
2
+ * Returns true when the file was written (content changed or file was new). */
3
+ export declare function writeIfChanged(filePath: string, content: string): boolean;
4
+ /**
5
+ * Emit the Zephyr application skeleton around the generated src/main.cpp.
6
+ *
7
+ * Layout written:
8
+ * <projectRoot>/
9
+ * CMakeLists.txt (find_package(Zephyr), target_sources app ← src/*.cpp)
10
+ * prj.conf (CONFIG_* for GPIO + C++ + libc)
11
+ * src/main.cpp (owned by cuttlefish's emit pipeline — NOT touched here)
12
+ *
13
+ * Idempotent. Mirrors scaffoldEspIdfProject's writeIfChanged discipline.
14
+ */
15
+ export declare function scaffoldZephyrProject(projectRoot: string, debug?: boolean, userKconfig?: Record<string, string>, psram?: 'opi' | 'quad'): boolean;
@@ -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
+ import { writeFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { resolveKconfigFragments } from '../dt-config/kconfig.js';
12
+ /** Write a file only if the content differs from the existing file.
13
+ * Returns true when the file was written (content changed or file was new). */
14
+ export function writeIfChanged(filePath, content) {
15
+ if (existsSync(filePath)) {
16
+ try {
17
+ if (readFileSync(filePath, 'utf8') === content)
18
+ return false;
19
+ }
20
+ catch {
21
+ // Read failed — fall through to write.
22
+ }
23
+ }
24
+ writeFileSync(filePath, content);
25
+ return true;
26
+ }
27
+ /**
28
+ * Concatenate all emitted source under src/ so the scaffold can detect which
29
+ * peripherals the program actually uses. The cuttlefish lowering emits
30
+ * well-known driver API tokens (adc_read, spi_transceive, bt_*, …), so scanning
31
+ * the post-transpile source is an authoritative usage signal — and it keeps the
32
+ * scaffold self-contained (no need to thread analysis through the toolchain
33
+ * contract). Returns '' when no sources exist yet (first prepare call).
34
+ */
35
+ function readEmittedSources(srcDir) {
36
+ if (!existsSync(srcDir))
37
+ 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
+ }
44
+ catch {
45
+ // ignore unreadable files
46
+ }
47
+ }
48
+ }
49
+ return out;
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, debug = false, userKconfig, psram) {
63
+ const srcDir = join(projectRoot, 'src');
64
+ if (!existsSync(srcDir))
65
+ mkdirSync(srcDir, { recursive: true });
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) => {
80
+ if (token.endsWith('_')) {
81
+ return new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(src);
82
+ }
83
+ return src.includes(token);
84
+ };
85
+ const usage = {
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
+ let changed = false;
117
+ // ── Root CMakeLists.txt ─────────────────────────────────────────────────
118
+ // The canonical Zephyr CMake application. GLOB src/*.cpp so future multi-
119
+ // file emits are picked up automatically; cuttlefish owns the file contents.
120
+ const cmakeLists = [
121
+ '# Auto-generated by @typecad/framework-zephyr from cuttlefish.config.ts.',
122
+ 'cmake_minimum_required(VERSION 3.20.0)',
123
+ '',
124
+ 'find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})',
125
+ '',
126
+ 'project(zephyr_app)',
127
+ '',
128
+ '# Collect cuttlefish-emitted sources.',
129
+ 'file(GLOB app_sources src/*.cpp src/*.c)',
130
+ '',
131
+ 'target_sources(app PRIVATE ${app_sources})',
132
+ // When PSRAM is configured, define BOARD_HAS_PSRAM so the UI runtime's
133
+ // PSRAM canvas allocator (ui_create_canvas_best) is compiled in.
134
+ ...(psram ? ['', '# PSRAM enabled: activate the runtime PSRAM canvas paths.', 'target_compile_definitions(app PRIVATE BOARD_HAS_PSRAM)', ''] : ['']),
135
+ ].join('\n');
136
+ if (writeIfChanged(join(projectRoot, 'CMakeLists.txt'), cmakeLists))
137
+ changed = true;
138
+ // ── prj.conf ────────────────────────────────────────────────────────────
139
+ // Driver Kconfig symbols are usage-gated on the emitted source: only a
140
+ // peripheral whose driver API the program actually references is enabled.
141
+ // Zephyr's Kconfig treats an enabled-but-unused driver as harmless, BUT
142
+ // several driver stacks pull in large/specific dependencies — notably
143
+ // NimBLE (CONFIG_BT) needs Espressif prebuilt blobs that are not always
144
+ // fetched (`west blobs fetch hal_espressif`). Usage-gating keeps a GPIO-only
145
+ // blink from requiring those, and shrinks the link for every program. The
146
+ // core GPIO driver and C++ support stay unconditional. Symbol selection is
147
+ // delegated to resolveKconfigFragments (unit-tested in dt-config/kconfig).
148
+ const symbols = resolveKconfigFragments(usage, debug);
149
+ const prjConf = [
150
+ '# Auto-generated by @typecad/framework-zephyr from cuttlefish.config.ts.',
151
+ '# Edit in cuttlefish.config.ts (frameworkData), not here.',
152
+ '# Driver symbols are usage-gated on the emitted source — only peripherals',
153
+ '# the program references are enabled.',
154
+ '',
155
+ '# Route printf/stdout to the console UART (needed by the @typecad/expect',
156
+ '# test runner protocol, which uses printf via __tc_print/__tc_println).',
157
+ 'CONFIG_STDOUT_CONSOLE=y',
158
+ 'CONFIG_PRINTK=y',
159
+ '',
160
+ ];
161
+ // Emit a section header before the BT block when present.
162
+ let btHeaderEmitted = false;
163
+ let wifiHeaderEmitted = false;
164
+ for (const [sym, val] of symbols) {
165
+ if (sym === 'CONFIG_BT' && !btHeaderEmitted) {
166
+ prjConf.push('', '# Bluetooth (NimBLE peripheral).');
167
+ btHeaderEmitted = true;
168
+ }
169
+ if (sym === 'CONFIG_WIFI' && !wifiHeaderEmitted) {
170
+ prjConf.push('', '# WiFi / networking (conn_mgr + esp32 wifi driver).');
171
+ wifiHeaderEmitted = true;
172
+ }
173
+ // Skip auto-detected symbols that the user explicitly overrides
174
+ // in cuttlefish.config.ts zephyr.kconfig — the user value is
175
+ // emitted in the User Kconfig section below and takes precedence.
176
+ if (userKconfig && userKconfig.hasOwnProperty(sym))
177
+ continue;
178
+ prjConf.push(`${sym}=${val}`);
179
+ }
180
+ // Emit user-specified Kconfig from cuttlefish.config.ts zephyr.kconfig.
181
+ // These override any matching auto-detected symbol (skipped above).
182
+ if (userKconfig) {
183
+ prjConf.push('', '# User Kconfig (cuttlefish.config.ts → zephyr.kconfig).');
184
+ for (const [sym, val] of Object.entries(userKconfig)) {
185
+ prjConf.push(`${sym}=${val}`);
186
+ }
187
+ }
188
+ // CONFIG_BT_DEVICE_NAME is a string value not produced by the resolver — add
189
+ // it after the BT block when BLE is used (parity with the previous inline form).
190
+ if (usage.usesBle)
191
+ prjConf.push('CONFIG_BT_DEVICE_NAME="TypeCAD"');
192
+ prjConf.push('');
193
+ if (writeIfChanged(join(projectRoot, 'prj.conf'), prjConf.join('\n')))
194
+ changed = true;
195
+ return changed;
196
+ }