@typecad/framework-zephyr 1.0.0-alpha.16 → 1.0.0-alpha.19

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 (210) hide show
  1. package/LICENSE +202 -21
  2. package/README.md +49 -87
  3. package/dist/as-built.d.ts +2 -2
  4. package/dist/as-built.js +2 -2
  5. package/dist/audit.d.ts +111 -0
  6. package/dist/audit.js +416 -0
  7. package/dist/boardgen.d.ts +1 -9
  8. package/dist/boardgen.js +288 -47
  9. package/dist/chips/resolve.js +20 -0
  10. package/dist/chips/types.d.ts +22 -1
  11. package/dist/debug-codegen.js +1 -1
  12. package/dist/display/bindings.d.ts +55 -0
  13. package/dist/display/bindings.js +316 -0
  14. package/dist/display/gfx.d.ts +2 -3
  15. package/dist/display/gfx.js +166 -154
  16. package/dist/display/index.js +20 -1
  17. package/dist/display/mipi-dbi-host.d.ts +9 -0
  18. package/dist/display/mipi-dbi-host.js +174 -0
  19. package/dist/display/profiles.d.ts +109 -4
  20. package/dist/display/profiles.js +270 -7
  21. package/dist/display/touch-adapter.js +119 -49
  22. package/dist/display/ui-adapter-eink.d.ts +2 -0
  23. package/dist/display/ui-adapter-eink.js +4 -0
  24. package/dist/display/ui-adapter-gray.d.ts +8 -0
  25. package/dist/display/ui-adapter-gray.js +170 -0
  26. package/dist/display/ui-adapter-mono.d.ts +13 -0
  27. package/dist/display/ui-adapter-mono.js +230 -0
  28. package/dist/display/ui-adapter-native.d.ts +10 -0
  29. package/dist/display/ui-adapter-native.js +295 -0
  30. package/dist/display/ui-adapter-shared.d.ts +11 -0
  31. package/dist/display/ui-adapter-shared.js +122 -0
  32. package/dist/display/ui-adapter.js +510 -558
  33. package/dist/doctor.js +4 -4
  34. package/dist/dt-config/custom-board.js +2 -2
  35. package/dist/dt-config/kconfig.d.ts +62 -1
  36. package/dist/dt-config/kconfig.js +141 -38
  37. package/dist/dt-config/overlay.d.ts +15 -2
  38. package/dist/dt-config/overlay.js +433 -18
  39. package/dist/framework.manifest.d.ts +10 -4
  40. package/dist/framework.manifest.js +133 -17
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +14 -6
  43. package/dist/licenses.d.ts +2 -2
  44. package/dist/licenses.js +13 -92
  45. package/dist/lowering/can.d.ts +25 -0
  46. package/dist/lowering/can.js +97 -0
  47. package/dist/lowering/clock.d.ts +17 -0
  48. package/dist/lowering/clock.js +58 -0
  49. package/dist/lowering/fs.js +1 -1
  50. package/dist/lowering/gpio.js +0 -32
  51. package/dist/lowering/hid.d.ts +27 -0
  52. package/dist/lowering/hid.js +244 -0
  53. package/dist/lowering/http.js +264 -32
  54. package/dist/lowering/i2c.d.ts +8 -0
  55. package/dist/lowering/i2c.js +137 -5
  56. package/dist/lowering/i2s.d.ts +27 -0
  57. package/dist/lowering/i2s.js +98 -0
  58. package/dist/lowering/index.d.ts +9 -1
  59. package/dist/lowering/index.js +25 -1
  60. package/dist/lowering/interrupts.js +6 -0
  61. package/dist/lowering/matrix.d.ts +15 -0
  62. package/dist/lowering/matrix.js +63 -0
  63. package/dist/lowering/mqtt.js +110 -8
  64. package/dist/lowering/power.d.ts +3 -2
  65. package/dist/lowering/power.js +20 -45
  66. package/dist/lowering/pwm.js +25 -0
  67. package/dist/lowering/sensor.d.ts +2 -2
  68. package/dist/lowering/sensor.js +8 -4
  69. package/dist/lowering/strip.d.ts +16 -0
  70. package/dist/lowering/strip.js +70 -0
  71. package/dist/lowering/thread.js +5 -1
  72. package/dist/lowering/trace.d.ts +44 -0
  73. package/dist/lowering/trace.js +239 -0
  74. package/dist/lowering/uart.js +6 -1
  75. package/dist/lowering/usb.d.ts +3 -1
  76. package/dist/lowering/usb.js +16 -13
  77. package/dist/lowering/wdt.js +2 -29
  78. package/dist/sbom.d.ts +181 -0
  79. package/dist/sbom.js +901 -0
  80. package/dist/sdk/board-catalog-sync.d.ts +1 -3
  81. package/dist/sdk/board-catalog-sync.js +4 -10
  82. package/dist/strategy.d.ts +82 -46
  83. package/dist/strategy.js +639 -182
  84. package/dist/tmp-probe.d.ts +2 -0
  85. package/dist/tmp-probe.js +9 -0
  86. package/dist/toolchain/debug-config.d.ts +50 -90
  87. package/dist/toolchain/debug-config.js +239 -510
  88. package/dist/toolchain/env-check.d.ts +1 -3
  89. package/dist/toolchain/env-check.js +2 -7
  90. package/dist/toolchain/index.d.ts +28 -3
  91. package/dist/toolchain/index.js +498 -62
  92. package/dist/toolchain/runners.d.ts +16 -0
  93. package/dist/toolchain/runners.js +75 -0
  94. package/dist/toolchain/scaffold.d.ts +5 -2
  95. package/dist/toolchain/scaffold.js +79 -15
  96. package/dist/toolchain/west-discover.d.ts +6 -0
  97. package/dist/toolchain/west-discover.js +36 -13
  98. package/dist/toolchain/west-spawn.js +8 -2
  99. package/dist/west-inventory.d.ts +25 -0
  100. package/dist/west-inventory.js +97 -0
  101. package/installer/README.md +328 -328
  102. package/installer/install.sh +2 -2
  103. package/installer/templates/project/.typecad/activate-zephyr.ps1 +1 -1
  104. package/installer/templates/project/.typecad/activate-zephyr.sh +1 -1
  105. package/installer/templates/project/.vscode/settings.json +1 -1
  106. package/installer/templates/project/README.md +2 -2
  107. package/package.json +5 -5
  108. package/src/as-built.ts +206 -206
  109. package/src/audit.ts +529 -0
  110. package/src/boardgen.ts +265 -50
  111. package/src/chips/resolve.ts +21 -0
  112. package/src/chips/types.ts +10 -1
  113. package/src/display/bindings.ts +347 -0
  114. package/src/display/gfx.ts +318 -306
  115. package/src/display/index.ts +87 -70
  116. package/src/display/mipi-dbi-host.ts +183 -0
  117. package/src/display/profiles.ts +458 -139
  118. package/src/display/touch-adapter.ts +119 -49
  119. package/src/display/ui-adapter-eink.ts +13 -0
  120. package/src/display/ui-adapter-gray.ts +178 -0
  121. package/src/display/ui-adapter-mono.ts +238 -0
  122. package/src/display/ui-adapter-native.ts +304 -0
  123. package/src/display/ui-adapter-shared.ts +125 -0
  124. package/src/display/ui-adapter.ts +732 -781
  125. package/src/doctor.ts +4 -4
  126. package/src/dt-config/custom-board.ts +2 -2
  127. package/src/dt-config/kconfig.ts +647 -505
  128. package/src/dt-config/overlay.ts +1475 -1058
  129. package/src/framework.manifest.ts +659 -535
  130. package/src/index.ts +16 -6
  131. package/src/licenses.ts +346 -425
  132. package/src/lowering/can.ts +140 -0
  133. package/src/lowering/clock.ts +91 -0
  134. package/src/lowering/fs.ts +135 -135
  135. package/src/lowering/gpio.ts +0 -33
  136. package/src/lowering/hid.ts +261 -0
  137. package/src/lowering/http.ts +264 -32
  138. package/src/lowering/i2c.ts +142 -5
  139. package/src/lowering/i2s.ts +143 -0
  140. package/src/lowering/index.ts +18 -1
  141. package/src/lowering/interrupts.ts +6 -0
  142. package/src/lowering/matrix.ts +70 -0
  143. package/src/lowering/mqtt.ts +109 -8
  144. package/src/lowering/power.ts +41 -0
  145. package/src/lowering/pwm.ts +192 -167
  146. package/src/lowering/sensor.ts +159 -155
  147. package/src/lowering/strip.ts +81 -0
  148. package/src/lowering/thread.ts +5 -1
  149. package/src/lowering/trace.ts +270 -0
  150. package/src/lowering/uart.ts +6 -1
  151. package/src/lowering/usb.ts +16 -13
  152. package/src/lowering/wdt.ts +2 -25
  153. package/src/sbom.ts +1117 -0
  154. package/src/sdk/board-catalog-sync.ts +4 -25
  155. package/src/strategy.ts +2680 -2309
  156. package/src/toolchain/debug-config.ts +262 -522
  157. package/src/toolchain/env-check.ts +279 -285
  158. package/src/toolchain/index.ts +1792 -1359
  159. package/src/toolchain/runners.ts +80 -0
  160. package/src/toolchain/scaffold.ts +355 -296
  161. package/src/toolchain/west-discover.ts +35 -13
  162. package/src/toolchain/west-spawn.ts +174 -168
  163. package/src/west-inventory.ts +102 -0
  164. package/dist/async/timer-polyfill.d.ts +0 -10
  165. package/dist/async/timer-polyfill.js +0 -95
  166. package/dist/chips/board-overrides.d.ts +0 -7
  167. package/dist/chips/board-overrides.js +0 -11
  168. package/dist/chips/esp32.d.ts +0 -2
  169. package/dist/chips/esp32.js +0 -71
  170. package/dist/chips/esp32s3.d.ts +0 -2
  171. package/dist/chips/esp32s3.js +0 -103
  172. package/dist/chips/soc/.d.ts +0 -2
  173. package/dist/chips/soc/.js +0 -129
  174. package/dist/chips/soc/esp32.d.ts +0 -2
  175. package/dist/chips/soc/esp32.js +0 -120
  176. package/dist/chips/soc/esp32c3.d.ts +0 -2
  177. package/dist/chips/soc/esp32c3.js +0 -90
  178. package/dist/chips/soc/esp32c6.d.ts +0 -2
  179. package/dist/chips/soc/esp32c6.js +0 -109
  180. package/dist/chips/soc/esp32s3.d.ts +0 -2
  181. package/dist/chips/soc/esp32s3.js +0 -189
  182. package/dist/chips/soc/index.d.ts +0 -2
  183. package/dist/chips/soc/index.js +0 -23
  184. package/dist/chips/soc/nrf52840.d.ts +0 -2
  185. package/dist/chips/soc/nrf52840.js +0 -130
  186. package/dist/chips/soc/rp2040.d.ts +0 -2
  187. package/dist/chips/soc/rp2040.js +0 -141
  188. package/dist/chips/soc/rp2350a.d.ts +0 -2
  189. package/dist/chips/soc/rp2350a.js +0 -145
  190. package/dist/chips/soc/samd21g18a.d.ts +0 -2
  191. package/dist/chips/soc/samd21g18a.js +0 -143
  192. package/dist/chips/soc/stm32f411xe.d.ts +0 -2
  193. package/dist/chips/soc/stm32f411xe.js +0 -251
  194. package/dist/chips/xiao-ble.d.ts +0 -2
  195. package/dist/chips/xiao-ble.js +0 -100
  196. package/dist/lowering/pulse.d.ts +0 -7
  197. package/dist/lowering/pulse.js +0 -51
  198. package/dist/lowering/tone.d.ts +0 -10
  199. package/dist/lowering/tone.js +0 -63
  200. package/dist/lowering/worker-backing.d.ts +0 -14
  201. package/dist/lowering/worker-backing.js +0 -79
  202. package/dist/lowering/worker.d.ts +0 -6
  203. package/dist/lowering/worker.js +0 -14
  204. package/dist/sdk/board-data.generated.d.ts +0 -2
  205. package/dist/sdk/board-data.generated.js +0 -4
  206. package/dist/sdk/catalog-walker.d.ts +0 -90
  207. package/dist/sdk/catalog-walker.js +0 -682
  208. package/dist/sdk/dts-reader.d.ts +0 -83
  209. package/dist/sdk/dts-reader.js +0 -596
  210. package/src/debug-codegen.ts +0 -207
@@ -1,1359 +1,1792 @@
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, delimiter, dirname, join } from 'node:path';
23
- import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync, writeFileSync } 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 { parseZephyrDts, asBuiltJson } from '../as-built.js';
28
- import { westSpawn, buildEnv } from './west-spawn.js';
29
- import { discoverWest } from './west-discover.js';
30
- import { writeDebugConfig, resolveDebugLocations } from './debug-config.js';
31
- import { bossacTouchReset } from './bossac-touch.js';
32
- import { ZephyrStrategy } from '../strategy.js';
33
- import { generateOverlay, type DisplayWiring, type TouchWiring, type OverlayDiagnostic } from '../dt-config/overlay.js';
34
- import { generateCustomBoard } from '../dt-config/custom-board.js';
35
- import { NO_BOARD_CHIP } from '../chips/index.js';
36
- import { resolveChipFromBoard } from '../chips/resolve.js';
37
- import type { ZephyrChipDescriptor } from '../chips/types.js';
38
- import { pwmDtAliasToken } from '../lowering/pwm.js';
39
- import { detectZephyrVersion, checkZephyrCompat, resolveBoardTarget } from './compat.js';
40
- import { DEFAULT_ZEPHYR_DISPLAY_PROFILE } from '../display/profiles.js';
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`). There is no registry fallback: a build
46
- * whose constants did not persist stays NO_BOARD_CHIP, exactly like the
47
- * emit-time path.
48
- */
49
- function chipForBuild(projectRoot: string, board: string): ZephyrChipDescriptor {
50
- try {
51
- // The transpile writes the constants into the emit outDir, which is
52
- // <projectRoot>/src for the standard layout (basename 'src' collapsed by
53
- // projectRootFromOptions); check both locations.
54
- const bcPath = [join(projectRoot, 'src', 'board-constants.json'), join(projectRoot, 'board-constants.json')]
55
- .find(p => existsSync(p));
56
- if (bcPath) {
57
- const raw = JSON.parse(readFileSync(bcPath, 'utf8')) as Record<string, string | number | boolean>;
58
- const bc = new Map(Object.entries(raw));
59
- const fromBoard = resolveChipFromBoard(bc);
60
- if (fromBoard) return fromBoard;
61
- }
62
- } catch { /* constants unreadable — no board resolved */ }
63
- return NO_BOARD_CHIP;
64
- }
65
-
66
- /**
67
- * HAL pins the emitted sources read via adc.* — scanned from the emitted
68
- * `__tc_adc<N>_setup()` call sites (N = channel index, mapped back to the HAL
69
- * pin via the chip descriptor). Feeds the overlay's ADC pinctrl rewrite: on
70
- * SoCs that mux ADC pads via pinctrl (STM32), only the read channels are
71
- * switched to analog mode.
72
- */
73
- function scanAdcReadPins(src: string, chip: ZephyrChipDescriptor): number[] {
74
- const pins: number[] = [];
75
- // Two used-signal forms:
76
- // - `__tc_adc<N>_setup()` / `__tc_adc_<ctrl>_<N>_setup()` CALL SITES
77
- // (empty parens — the adcInitLines definitions have `(void)` and would
78
- // otherwise mark every descriptor channel as used).
79
- // - `__tc_adct<pin>_done` lazy-guard vars — the thin-ADC read lowering's
80
- // inline setup (families without pinctrl groups, e.g. ESP32 SARADC,
81
- // never emit the setup-function form at all).
82
- // Channel indices are unique per CONTROLLER, so the labeled form resolves
83
- // (controller, channel) before mapping back to the HAL pin.
84
- for (const m of src.matchAll(/__tc_adc(?:(\w+?)_)?(\d+)_setup\(\)/g)) {
85
- const ch = Number(m[2]);
86
- const label = m[1];
87
- const c = chip.adc?.channels.find(
88
- (x) => x.channel === ch && (x.controller ?? chip.adc?.nodeLabel) === (label ?? chip.adc?.nodeLabel),
89
- );
90
- if (c && !pins.includes(c.pin)) pins.push(c.pin);
91
- }
92
- for (const m of src.matchAll(/__tc_adct(\d+)_done/g)) {
93
- const pin = Number(m[1]);
94
- if (chip.adc?.channels.some((x) => x.pin === pin) && !pins.includes(pin)) pins.push(pin);
95
- }
96
- return pins;
97
- }
98
-
99
- /**
100
- * HAL pins the emitted sources drive with dac.* — the DAC lowering's lazy
101
- * per-pin setup guard is `__tc_dact<pin>_done` (lowering/dac.ts), so
102
- * var-presence is the authoritative used-signal. Feeds the overlay's DAC
103
- * pinctrl gating (same pattern as scanAdcReadPins).
104
- */
105
- function scanDacWritePins(src: string): number[] {
106
- const pins: number[] = [];
107
- for (const m of src.matchAll(/__tc_dact(\d+)_done/g)) {
108
- const pin = Number(m[1]);
109
- if (!pins.includes(pin)) pins.push(pin);
110
- }
111
- return pins;
112
- }
113
-
114
- /**
115
- * HAL pins the emitted sources drive with pwm.* — the emitted source
116
- * references each used spec as `__tc_pwm_<alias token>` (pwmVarName in
117
- * lowering/pwm.ts), and the lowering only emits specs for driven pins, so
118
- * var-presence is the authoritative signal. Feeds the overlay's per-pin
119
- * pwm-leds gating (no dead DT channels).
120
- */
121
- /**
122
- * Inline-override markers (the escape hatch): the adc/pwm lowerings emit
123
- * `/* cuttlefish-user-facts: <kind> pin=N [device=X] [pinctrl=P] channel=C *​/`
124
- * comments when a construction carries routing overrides. Merged into the
125
- * chip so the overlay synthesis + used-pin scans treat them as facts —
126
- * the transpiler cannot create DT nodes, but this regen can.
127
- */
128
- function applyUserFactMarkers(chip: ZephyrChipDescriptor, src: string): ZephyrChipDescriptor {
129
- const adcAdds: { pin: number; channel: number; controller?: string; pinctrl?: string }[] = [];
130
- const pwmAdds: { pin: number; controller: string; channel: number }[] = [];
131
- for (const m of src.matchAll(/\/\* cuttlefish-user-facts: (adc|pwm) ([^*]*?) \*\//g)) {
132
- const kind = m[1];
133
- const fields = new Map<string, string>();
134
- for (const kv of m[2]!.split(/\s+/).filter(Boolean)) {
135
- const eq = kv.indexOf('=');
136
- if (eq > 0) fields.set(kv.slice(0, eq), kv.slice(eq + 1));
137
- }
138
- const pin = Number(fields.get('pin'));
139
- const channel = Number(fields.get('channel'));
140
- if (!Number.isFinite(pin) || !Number.isFinite(channel)) continue;
141
- if (kind === 'adc') {
142
- adcAdds.push({
143
- pin,
144
- channel,
145
- ...(fields.get('device') ? { controller: fields.get('device') } : {}),
146
- ...(fields.get('pinctrl') ? { pinctrl: fields.get('pinctrl') } : {}),
147
- });
148
- } else if (fields.get('controller')) {
149
- pwmAdds.push({ pin, channel, controller: fields.get('controller')! });
150
- }
151
- }
152
- if (adcAdds.length === 0 && pwmAdds.length === 0) return chip;
153
- const adc = chip.adc
154
- ? chip.adc
155
- : { nodeLabel: adcAdds.find((a) => !a.controller)?.controller ?? 'adc', resolution: 12, vrefMv: 3000, channels: [] };
156
- const adcChannels = [...adc.channels];
157
- for (const a of adcAdds) {
158
- const existing = adcChannels.findIndex((c) => c.pin === a.pin);
159
- if (existing >= 0) adcChannels.splice(existing, 1);
160
- adcChannels.push({ pin: a.pin, channel: a.channel, ...(a.controller ? { controller: a.controller } : {}), ...(a.pinctrl ? { pinctrl: a.pinctrl } : {}) });
161
- }
162
- const pwmSpecs = [...(chip.pwm?.specs ?? [])];
163
- for (const p of pwmAdds) {
164
- const existing = pwmSpecs.findIndex((s) => s.pin === p.pin);
165
- if (existing >= 0) pwmSpecs.splice(existing, 1);
166
- pwmSpecs.push({ pin: p.pin, controller: p.controller, channel: p.channel });
167
- }
168
- return {
169
- ...chip,
170
- adc: { ...adc, channels: adcChannels },
171
- pwm: { ...(chip.pwm ?? { specs: [] }), specs: pwmSpecs },
172
- };
173
- }
174
-
175
- function scanPwmUsedPins(src: string, chip: ZephyrChipDescriptor): number[] { const pins = (chip.pwm?.specs ?? [])
176
- .filter((s) => src.includes(`__tc_pwm_${pwmDtAliasToken(s)}`))
177
- .map((s) => s.pin);
178
- // Matrix pins (ESP32 LEDC) have no static specs — recover the driven pins
179
- // from the same alias-var presence signal (`__tc_pwm_tc_pwm<N>`), keeping
180
- // only pins the descriptor's matrix allows (the regex grabs the full
181
- // number, so pin 4 never matches a reference to pin 45).
182
- const matrix = chip.pwm?.matrix;
183
- if (matrix) {
184
- const present = new Set<number>();
185
- for (const match of src.matchAll(/__tc_pwm_tc_pwm(\d+)/g)) {
186
- present.add(Number(match[1]));
187
- }
188
- for (const pin of matrix.pins) {
189
- if (present.has(pin)) pins.push(pin);
190
- }
191
- }
192
- return pins;
193
- }
194
-
195
- /**
196
- * Bus controller indexes the emitted sources actually reference — the shim
197
- * declares one `__tc_<bus><N>_dev` state block per used instance (gated by
198
- * collectUsedBusIndices at transpile time), so var-presence is the
199
- * authoritative signal. The overlay enables only these controllers: an
200
- * enabled-but-unused one claims its default pins (i2c0's GP4/GP5 on the
201
- * Pico) which a program driving the OTHER controller may want as GPIO.
202
- * Empty list (no state blocks — e.g. display/touch composites that use the
203
- * driver API directly) means "no signal"; the caller then passes undefined
204
- * so the overlay enables every declared controller, preserving old behavior.
205
- */
206
- function scanUsedBusInstances(
207
- src: string,
208
- controllers: readonly { nodeLabel: string }[] | undefined,
209
- bus: 'i2c' | 'spi' | 'uart',
210
- ): number[] | undefined {
211
- if (!controllers) return undefined;
212
- const used: number[] = [];
213
- for (let i = 0; i < controllers.length; i++) {
214
- if (src.includes(`__tc_${bus}${i}_dev`)) used.push(i);
215
- }
216
- return used.length > 0 ? used : undefined;
217
- }
218
-
219
- /**
220
- * Distinct DT-bound sensors the emitted sources reference — the lowering's
221
- * state blocks name each one `__tc_sensor_<part>_i2c<N>_0x<addr>_dev`
222
- * (lowering/sensor.ts sensorNames), so var-presence is the authoritative
223
- * signal. Feeds the overlay's DT child nodes; the part group is greedy so a
224
- * compatible containing '_i2c<N>_' still resolves to the longest part match.
225
- */
226
- export interface ScannedSensorPart {
227
- part: string; busIndex: number; port: number; busKind: 'i2c' | 'spi';
228
- spiHz: number; spiMode: number; alertPin: number;
229
- }
230
-
231
- export interface ScannedSpiTarget {
232
- busIndex: number; cs: number; hz: number; mode: number;
233
- }
234
-
235
- /** Scan the emitted source for thin SPI targets (hal/spi-target.ts): the
236
- * spi_dt_spec state block's tc-spit-cfg comment carries the construction
237
- * facts, the same channel tc-sensor-cfg uses. */
238
- export function scanSpiTargets(src: string): ScannedSpiTarget[] {
239
- const out = new Map<string, ScannedSpiTarget>();
240
- for (const m of src.matchAll(/tc-spit-cfg: tc_spit_spi(\d+)_cs(\d+) hz=(\d+) mode=(\d+)/g)) {
241
- const ref: ScannedSpiTarget = { busIndex: parseInt(m[1], 10), cs: parseInt(m[2], 10), hz: parseInt(m[3], 10), mode: parseInt(m[4], 10) };
242
- out.set(`${ref.busIndex}|${ref.cs}`, ref);
243
- }
244
- return [...out.values()];
245
- }
246
-
247
- export function scanSensorParts(src: string): ScannedSensorPart[] {
248
- const out = new Map<string, ScannedSensorPart>();
249
- for (const m of src.matchAll(/__tc_sensor_([a-z0-9_]+)_(i2c|spi)(\d+)_(0x[0-9a-f]+|cs[0-9]+)_dev\b/g)) {
250
- const port = m[4].startsWith('0x') ? parseInt(m[4], 16) : parseInt(m[4].slice(2), 10);
251
- const ref: ScannedSensorPart = { part: m[1], busIndex: parseInt(m[3], 10), port, busKind: m[2] as 'i2c' | 'spi', spiHz: 0, spiMode: 0, alertPin: -1 };
252
- out.set(`${ref.part}|${ref.busKind}${ref.busIndex}|${ref.port}`, ref);
253
- }
254
- // Construction facts ride the state block's config comment — merge by
255
- // nodelabel so the scanner stays the single source for the overlay.
256
- for (const m of src.matchAll(/tc-sensor-cfg: tc_([a-z0-9_]+)_(i2c|spi)(\d+)_(0x[0-9a-f]+|cs[0-9]+) hz=(\d+) mode=(\d+) alert=(-?\d+)/g)) {
257
- const port = m[4].startsWith('0x') ? parseInt(m[4], 16) : parseInt(m[4].slice(2), 10);
258
- const key = `${m[1]}|${m[2]}${m[3]}|${port}`;
259
- const existing = out.get(key);
260
- if (existing) {
261
- existing.spiHz = parseInt(m[5], 10);
262
- existing.spiMode = parseInt(m[6], 10);
263
- existing.alertPin = parseInt(m[7], 10);
264
- }
265
- }
266
- return [...out.values()];
267
- }
268
-
269
- function targetFromOptions(o: ToolchainOptions): string {
270
- // The cuttlefish CLI populates ToolchainOptions.buildTarget from the
271
- // config's board: (frameworkData.buildTarget for board-less projects).
272
- // Accept frameworkData.target as an alias. No silent default: building for
273
- // a wrong hard-coded board is the split-brain trap.
274
- const fcTarget = (o.frameworkConfig?.target as string | undefined);
275
- const board = (o.buildTarget as string | undefined) ?? fcTarget;
276
- if (!board) {
277
- throw new Error(
278
- 'No build target: set board: in cuttlefish.config.ts (or frameworkData.buildTarget for custom-board projects).',
279
- );
280
- }
281
- return board;
282
- }
283
-
284
- /**
285
- * Derive the Zephyr project root from the cuttlefish-emitted source path.
286
- *
287
- * Cuttlefish emits `src/main.cpp` under the output dir. The CLI passes
288
- * `sourcePath` = full path to `main.cpp` and `outputDir` = its parent (`src/`).
289
- * For Zephyr, the project root is the parent of `src/` — one level above
290
- * `outputDir`. Detect that shape and adjust; otherwise fall back to `outputDir`.
291
- */
292
- export function projectRootFromOptions(o: ToolchainOptions): string {
293
- const outDir = o.outputDir;
294
- if (basename(outDir) === 'src') {
295
- return dirname(outDir);
296
- }
297
- return outDir;
298
- }
299
-
300
- /**
301
- * west build timeout. Zephyr's first build fetches the toolchain modules and
302
- * configures CMake/Ninja, which can take several minutes; allow generous headroom.
303
- */
304
- const BUILD_TIMEOUT_MS = 600_000;
305
- const FLASH_TIMEOUT_MS = 120_000;
306
-
307
- /**
308
- * Build the `west flash` argument list for a board.
309
- *
310
- * Runner selection: each board's board.cmake declares a sensible default flash
311
- * runner for its hardware (xiao_ble → nrfutil, esp32* → esptool), and `west
312
- * flash` resolves it automatically. The framework only intervenes where the
313
- * board default needs an argument it can't infer:
314
- * - An explicit `zephyr.runner` (from cuttlefish.config.ts) always wins.
315
- * - ESP32 boards forward the port via `--esp-device` (esptool reads the
316
- * device from it); board.cmake still picks the runner.
317
- * - Every other board trusts the board.cmake default. Previously this forced
318
- * `--runner nrfjprog` for every non-ESP32 target, which broke boards whose
319
- * default is not nrfjprog (xiao_ble defaults to nrfutil) and required
320
- * Nordic J-Link tools that a USB-bootloader board does not have.
321
- *
322
- * Exported (pure) so the runner-selection contract is unit-testable without
323
- * spawning west.
324
- */
325
- /**
326
- * Resolve HOW this build attaches to the board for flashing OR debugging:
327
- * the friendly `zephyr.probe` id from the board's probeMethods table (quirks
328
- * included), or the raw `zephyr.runner` escape hatch. Exported (pure) so the
329
- * selection contract is unit-testable without spawning west.
330
- *
331
- * Rules:
332
- * - `probe` + `runner` together is an error (two ways of saying it — pick one).
333
- * - An unknown `probe` id is an error listing what the board supports; a board
334
- * with no probeMethods table gets a hint to use `runner` directly.
335
- * - purpose 'debug': the chosen method must be debug-capable (`debug` is not
336
- * false — a bootloader is not a debugger). Non-capable or unknown ids list
337
- * the debug-capable methods.
338
- * - User `runnerArgs` are appended AFTER the method's args, so they can
339
- * override the method's baked-in flags (argparse takes the last value).
340
- */
341
- export type ProbeResolution =
342
- | { ok: true; runner?: string; args: string[] }
343
- | { ok: false; error: string };
344
-
345
- export function resolveProbeMethod(
346
- zc: Record<string, unknown> | undefined,
347
- chip: ZephyrChipDescriptor,
348
- purpose: 'flash' | 'debug' = 'flash',
349
- ): ProbeResolution {
350
- const probe = zc?.probe as string | undefined;
351
- const runner = zc?.runner as string | undefined;
352
- const userArgs = (zc?.runnerArgs as string[] | undefined) ?? [];
353
-
354
- if (probe && runner) {
355
- return {
356
- ok: false,
357
- error:
358
- `cuttlefish.config.ts sets both zephyr.probe ('${probe}') and zephyr.runner ('${runner}'). ` +
359
- `They are two ways to choose the probe method — remove one.`,
360
- };
361
- }
362
-
363
- if (probe) {
364
- const methods = chip.probeMethods ?? [];
365
- const method = methods.find((m) => m.id === probe);
366
- if (!method) {
367
- const listAll = methods
368
- .map((m) => `${m.id} (${m.runner}${m.description ? ` — ${m.description}` : ''})`)
369
- .join('; ');
370
- return {
371
- ok: false,
372
- error: methods.length > 0
373
- ? `Unknown probe method '${probe}' for ${chip.id}. Supported: ${listAll}.`
374
- : `This board (${chip.id}) ships no probe-method table, so 'zephyr.probe' cannot resolve '${probe}'. ` +
375
- `Use the raw 'zephyr.runner' field instead (run 'west flash --context' in the build dir for options).`,
376
- };
377
- }
378
- if (purpose === 'debug' && method.debug === false) {
379
- const debuggable = methods.filter((m) => m.debug !== false).map((m) => m.id).join(', ');
380
- return {
381
- ok: false,
382
- error:
383
- `The '${probe}' method cannot debug ${chip.id} — a bootloader is not a debugger. ` +
384
- `Debug-capable methods: ${debuggable || '(none — this board needs an external probe)'}.`,
385
- };
386
- }
387
- return {
388
- ok: true,
389
- runner: method.runner,
390
- args: [...(method.args ?? []), ...userArgs],
391
- };
392
- }
393
-
394
- return { ok: true, runner, args: userArgs };
395
- }
396
-
397
- /**
398
- * Run an openocd session against the board's probe config — the shared
399
- * engine for the pre-flash quiesce and the post-flash SYSRESETREQ (see the
400
- * call sites in upload()). The config is the probe method's verbatim
401
- * debugCfg from the board catalog (the same lines `west debug` uses);
402
- * `commands` are appended after `-f <cfg> -c init`. Returns a flash note on
403
- * success, undefined when skipped or failed (best-effort by design).
404
- */
405
- /**
406
- * Resolve the openocd binary (plus script search dirs) for the probe session.
407
- *
408
- * Resolution order mirrors how west's own openocd runner finds the binary, so
409
- * the session and `west flash` drive the SAME openocd:
410
- * 1. $OPENOCD — the variable Zephyr's CMake reads into the build cache.
411
- * 2. A Zephyr-SDK-hosted install ($ZEPHYR_SDK_INSTALL_DIR, else the
412
- * discovered west install's SDK). Layout differs by SDK generation —
413
- * hosttools/openocd/share/openocd/scripts vs hosttools/openocd/scripts —
414
- * so both script dirs are collected.
415
- * 3. PATH — Linux distro / conda / micromamba installs (a micromamba-env
416
- * openocd is the common Linux setup; the SDK layout check alone made the
417
- * deterministic probe session unreachable there, silently dropping every
418
- * Linux flash to the racy `west flash` fallback).
419
- *
420
- * A non-SDK binary resolves with no explicit -s dirs: it finds its own
421
- * interface/target scripts via its compiled-in search path. Returns undefined
422
- * when no openocd can be found (the caller then uses the west fallback).
423
- *
424
- * Exported (pure) so the resolution contract is unit-testable without
425
- * spawning openocd.
426
- */
427
- export interface SessionOpenOcd {
428
- readonly exe: string;
429
- readonly searchDirs: readonly string[];
430
- }
431
-
432
- export function resolveSessionOpenOcd(
433
- env: {
434
- OPENOCD?: string | undefined;
435
- ZEPHYR_SDK_INSTALL_DIR?: string | undefined;
436
- PATH?: string | undefined;
437
- } = process.env,
438
- sdkInstallDir?: string,
439
- ): SessionOpenOcd | undefined {
440
- const exeName = process.platform === 'win32' ? 'openocd.exe' : 'openocd';
441
- if (env.OPENOCD && existsSync(env.OPENOCD)) {
442
- return { exe: env.OPENOCD, searchDirs: [] };
443
- }
444
- const sdkRoot = env.ZEPHYR_SDK_INSTALL_DIR || sdkInstallDir;
445
- if (sdkRoot) {
446
- const exe = join(sdkRoot, 'hosttools', 'openocd', 'bin', exeName);
447
- if (existsSync(exe)) {
448
- const searchDirs = [
449
- join(sdkRoot, 'hosttools', 'openocd', 'share', 'openocd', 'scripts'),
450
- join(sdkRoot, 'hosttools', 'openocd', 'scripts'),
451
- ].filter((d) => existsSync(d));
452
- return { exe, searchDirs };
453
- }
454
- }
455
- for (const dir of (env.PATH ?? '').split(delimiter)) {
456
- if (!dir) continue;
457
- const candidate = join(dir, exeName);
458
- if (existsSync(candidate)) return { exe: candidate, searchDirs: [] };
459
- }
460
- return undefined;
461
- }
462
-
463
- function openocdProbeSession(
464
- buildDir: string,
465
- zc: Record<string, unknown> | undefined,
466
- chip: ZephyrChipDescriptor,
467
- commands: readonly string[],
468
- ): string | undefined {
469
- // Config resolution — two sources, in order:
470
- // 1. The named probe method's verbatim debugCfg from the board catalog
471
- // (written to a temp cfg), when zephyr.probe names a method that has
472
- // one.
473
- // 2. The board's own support/openocd.cfg in the Zephyr tree — the exact
474
- // config `west flash` resolves for the openocd runner. This covers
475
- // raw `zephyr.runner: 'openocd'` (no named probe) and probe methods
476
- // that ship no debugCfg of their own.
477
- const probeId = zc?.probe as string | undefined;
478
- const method = chip.probeMethods?.find((m) => m.id === probeId);
479
- const cfgLines = method?.debugCfg;
480
- // Session reset policy: `reset_config none`. The session's resets are
481
- // core-domain by design — vector-catch halt before the flash write,
482
- // SYSRESETREQ to boot — so they must not depend on the SRST pin. Boards
483
- // like the WeAct Black Pill don't break NRST out at all: under the board
484
- // cfg's `srst_only`, every `reset` asserts a pin that reaches nothing
485
- // (the target never resets, `reset halt` catches the core mid-app in
486
- // dirty state and the flash algorithm times out) while the probe's
487
- // floating SRST sense reports phantom "external reset detected" events
488
- // that leave the session's halt state inconsistent. Method-declared west
489
- // quirks (`--cmd-pre-init=…`) are appended after and override the
490
- // default for boards whose facts carry one.
491
- const preInit = [
492
- 'reset_config none',
493
- ...(method?.args ?? [])
494
- .filter((a) => a.startsWith('--cmd-pre-init='))
495
- .map((a) => a.slice('--cmd-pre-init='.length)),
496
- ];
497
-
498
- const install = discoverWest();
499
- const sessionOpenOcd = resolveSessionOpenOcd(process.env, install?.sdkInstallDir);
500
- if (!sessionOpenOcd) return undefined;
501
- const openocdExe = sessionOpenOcd.exe;
502
- const searchArgs = sessionOpenOcd.searchDirs.flatMap((d) => ['-s', d] as [string, string]);
503
-
504
- let cfgArgs: string[] | undefined;
505
- let sessionCfg: string | undefined;
506
- if (cfgLines && cfgLines.length > 0) {
507
- sessionCfg = join(buildDir, 'cuttlefish-probe.cfg');
508
- } else {
509
- // The board target's qualifier ('blackpill_f411ce/stm32f411xe' →
510
- // 'blackpill_f411ce') identifies the board dir; the vendor segment is
511
- // not part of the target, so probe the boards/ tree for it.
512
- const zephyrBase = process.env.ZEPHYR_BASE || install?.zephyrBase;
513
- const boardDir = (chip.id ?? '').split('/')[0];
514
- if (!zephyrBase || !boardDir) return undefined;
515
- const boardsRoot = join(zephyrBase, 'boards');
516
- let supportCfg: string | undefined;
517
- try {
518
- for (const vendor of readdirSync(boardsRoot)) {
519
- const candidate = join(boardsRoot, vendor, boardDir, 'support', 'openocd.cfg');
520
- if (existsSync(candidate)) { supportCfg = candidate; break; }
521
- }
522
- } catch {
523
- return undefined;
524
- }
525
- if (!supportCfg) return undefined;
526
- cfgArgs = ['-s', dirname(supportCfg), '-f', supportCfg];
527
- }
528
-
529
- try {
530
- mkdirSync(buildDir, { recursive: true });
531
- if (sessionCfg) {
532
- writeFileSync(sessionCfg, cfgLines!.join('\n') + '\n', 'utf-8');
533
- cfgArgs = ['-f', sessionCfg];
534
- }
535
- const res = spawnSync(openocdExe, [
536
- ...searchArgs, ...cfgArgs!,
537
- // Pre-init TCL AFTER the cfg (overrides its reset_config) and BEFORE
538
- // init — the same position west gives --cmd-pre-init.
539
- ...preInit.map((c) => ['-c', c] as [string, string]).flat(),
540
- '-c', 'init',
541
- ...commands.map((c) => ['-c', c]).flat(),
542
- '-c', 'shutdown',
543
- ], {
544
- cwd: buildDir,
545
- encoding: 'utf-8' as const,
546
- timeout: 20_000,
547
- });
548
- return res.status === 0 ? `-- probe session ok: ${commands.join('; ')}` : undefined;
549
- } catch {
550
- return undefined;
551
- }
552
- }
553
-
554
- /**
555
- * Whether a flash runner carries the upload over a serial port. Runner-gated,
556
- * never board-name-gated: esptool and bossac are the only runners
557
- * `buildFlashArgs` forwards `--port` to, so they are the only ones that
558
- * cannot flash without one. Probe runners (openocd, jlink) and USB flows
559
- * (dfu-util, uf2 mass storage) need no port — a missing `--port` must not
560
- * block them.
561
- *
562
- * Exported (pure) so the port-requirement contract is unit-testable without
563
- * spawning west.
564
- */
565
- export function uploadRequiresPort(runner: string | undefined): boolean {
566
- return runner === 'esptool' || runner === 'bossac';
567
- }
568
-
569
- export function buildFlashArgs(
570
- buildDir: string,
571
- userRunner: string | undefined,
572
- port: string | undefined,
573
- flashRunner?: string,
574
- runnerArgs?: readonly string[],
575
- ): string[] {
576
- // flashRunner is the runner the flash will actually use — the explicit
577
- // zephyr.runner when set, else the board's declared default from its probe
578
- // table. It gates the esptool port forwarding below; only an EXPLICIT
579
- // userRunner forces west's --runner (board defaults stay board.cmake's
580
- // choice). Runner-gated, never board-name-gated: any board whose flash
581
- // runs esptool gets the same forwarding.
582
- const args = ['flash', '-d', buildDir];
583
- if (userRunner) {
584
- args.push('--runner', userRunner);
585
- }
586
- if (port && flashRunner === 'esptool') {
587
- args.push('--esp-device', port);
588
- }
589
- // The bossac runner defaults its port to /dev/ttyACM0 — on Windows that
590
- // never matches, so the port MUST be forwarded or bossac fails with
591
- // "No device found on /dev/ttyACM0" (same class of port-forwarding
592
- // problem as the esptool --esp-device above).
593
- if (port && flashRunner === 'bossac') {
594
- args.push('--bossac-port', port);
595
- }
596
- // Extra runner-specific flags, appended verbatim (west's runner parsers
597
- // accept them after the runner is selected).
598
- if (runnerArgs && runnerArgs.length > 0) {
599
- args.push(...runnerArgs);
600
- }
601
- return args;
602
- }
603
-
604
- /**
605
- * Classify a `west flash` result as success/failure.
606
- *
607
- * west's exit status is authoritative except for one known race in the uf2
608
- * runner on Windows: the UF2 bootloader reboots to run new firmware the instant
609
- * the file copy completes, unmounting the USB-MSC drive before `shutil.copy`'s
610
- * trailing `copymode`/chmod runs. That raises `OSError: [WinError 433] A
611
- * device which does not exist was specified` and makes west exit non-zero —
612
- * even though the firmware copied and flashed correctly (the LED blinks).
613
- *
614
- * The copy starting is logged ("Copying UF2 file to '<drive>'"); WinError 433
615
- * during `copymode` after that point proves the data write finished and the
616
- * drive only vanished on the metadata step. Treat that exact signature as
617
- * success so the upload isn't reported as a failure. Genuine uf2 failures
618
- * (no partition found, write errors before the copy) still surface as failures.
619
- *
620
- * Exported (pure) so the classification is unit-testable without spawning west.
621
- */
622
- export function classifyUploadResult(
623
- runner: string | undefined,
624
- status: number | null,
625
- output: string,
626
- ): boolean {
627
- if (status === 0) return true;
628
- if (isUf2DriveVanishRace(output)) return runner === 'uf2';
629
- return false;
630
- }
631
-
632
- /**
633
- * Whether `output` carries the benign UF2 copymode/WinError-433 race signature
634
- * (see classifyUploadResult). Centralized so classify + cleanse share one match.
635
- */
636
- function isUf2DriveVanishRace(output: string): boolean {
637
- return /Copying UF2 file to/.test(output)
638
- && /WinError 433/.test(output)
639
- && /copymode/.test(output);
640
- }
641
-
642
- /**
643
- * Whether a `west flash` (openocd) output carries one of the known
644
- * target-ignored-SWD signatures — the DAP connect failing ("init mode
645
- * failed (unable to connect to the target)", i.e. the DPIDR read never
646
- * succeeded) or a reset/halt never landing ("timed out while waiting for
647
- * target halted" / "TARGET: <name> - Not halted"). Both mean the board (or
648
- * probe) needs a power-cycle or the SWD-free DFU path, not a retry of the
649
- * same command. Centralized so the upload hint stays testable.
650
- */
651
- export function isTargetSwdFailure(output: string): boolean {
652
- return /unable to connect to the target|timed out while waiting for target halted|TARGET: \S+ - Not halted/.test(output);
653
- }
654
-
655
- /**
656
- * Cleanse the `west flash` output shown to the user.
657
- *
658
- * When classifyUploadResult has decided a non-zero west exit was the benign UF2
659
- * race (firmware copied, drive unmounted on the trailing chmod), the raw output
660
- * is a wall of Python traceback that reads like a hard failure. Drop everything
661
- * after the "Copying UF2 file to" line — i.e. the entire traceback — so a
662
- * successful flash reads as a success (the framework's ✓ Done follows). Non-race
663
- * output is returned untouched; genuine errors stay fully visible for diagnosis.
664
- *
665
- * Exported (pure) so the cleansing is unit-testable without spawning west.
666
- */
667
- export function cleanseUploadOutput(
668
- runner: string | undefined,
669
- status: number | null,
670
- output: string,
671
- ): string {
672
- if (status === 0) return output;
673
- if (runner === 'uf2' && isUf2DriveVanishRace(output)) {
674
- // Keep everything west printed up to and including "Copying UF2 file to",
675
- // then stop — everything after that is the drive-vanish traceback.
676
- const upto = output.match(/[\s\S]*Copying UF2 file to[^\n]*/);
677
- const head = upto ? upto[0] : '-- west flash: using runner uf2';
678
- return head;
679
- }
680
- return output;
681
- }
682
-
683
-
684
- /**
685
- * Whether a failed `west build` output carries ninja's `dependency cycle`
686
- * signature. Zephyr 4.3.99-dev snapshots have a regression
687
- * (zephyrproject-rtos/zephyr#104757, fixed upstream by the #104784 revert,
688
- * in v4.4+): after CMake re-runs from a .config change, the build dir's
689
- * .ninja_deps records an `offsets.h -> offsets.c.obj -> offsets.h` cycle and
690
- * ninja aborts with `ninja: error: dependency cycle: ...` before compiling
691
- * anything. The cycle lives in the build dir, not the sources, so compile()
692
- * recovers by deleting the dir and retrying once.
693
- *
694
- * Exported (pure) so the detection is unit-testable without spawning west.
695
- */
696
- export function isDependencyCycleFailure(output: string): boolean {
697
- return output.includes('dependency cycle');
698
- }
699
-
700
- /** stdout+stderr of a spawnSync result coerced to one string. Defensive about
701
- * the buffer form (spawnSync only returns strings when `encoding` is set,
702
- * which every call site here does — but the coercion costs nothing). */
703
- function combinedSpawnOutput(
704
- result: { stdout?: string | Buffer | null; stderr?: string | Buffer | null },
705
- ): string {
706
- const so = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
707
- const se = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
708
- return so + se;
709
- }
710
-
711
- /**
712
- * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
713
- * The target board is carried via frameworkData.buildTarget; scaffolding
714
- * happens at compile time when the target is known.
715
- */
716
- export const Toolchain = {
717
- prepare(outputDir: string, entryPoint: string): void {
718
- // Write the DT overlay for the default board (the real target is known at
719
- // compile time; prepare runs before compile, so use the default board id).
720
- // The overlay is additive and idempotent; compile re-runs prepare-equivalent
721
- // logic in scaffold via the usage scan. Mirrors how Arduino's library
722
- // resolution is a pre-build artifact step.
723
- const projectRoot = basename(outputDir) === 'src' ? dirname(outputDir) : outputDir;
724
- // Scan the emitted source for usage tokens (same authoritative signal the
725
- // scaffold uses). entryPoint is the path to main.cpp; its dir is src/.
726
- const srcDir = dirname(entryPoint);
727
- let src = '';
728
- try {
729
- for (const name of readdirSync(srcDir)) {
730
- if (name.endsWith('.cpp') || name.endsWith('.c')) {
731
- src += readFileSync(join(srcDir, name), 'utf8');
732
- }
733
- }
734
- } catch { /* src may not exist yet on first prepare */ }
735
- const uses = (t: string): boolean => src.includes(t);
736
- // Inline-override markers (the escape hatch): the lowerings emit
737
- // `cuttlefish-user-facts` comments carrying routing the transpiler
738
- // cannot synthesize (adc device/pinctrl, pwm controller/channel).
739
- // Merged into the chip BEFORE the scans + overlay generation, so the DT
740
- // nodes, pinctrl groups, and used-pin recovery treat them as facts.
741
- const chip = applyUserFactMarkers(chipForBuild(projectRoot, ''), src);
742
- // Display usage tokens: the minimal GFX runtime (display_write/_fill_rect)
743
- // and the UI display adapter (display_init / __tc_display_dev /
744
- // DEVICE_DT_GET on the display nodelabel). Both paths need the DT overlay
745
- // to enable the display node.
746
- const usesDisplay = uses('display_write') || uses('display_init')
747
- || uses('display_fill_rect') || uses('__tc_display_dev')
748
- || uses('CuttlefishDisplayTarget');
749
- // Both registered Zephyr display profiles use dtLabel 'display0', so the
750
- // default profile's overlay block (&display0 { status="okay" }) is correct
751
- // for either driver. Thread a non-default profile here only if a future
752
- // board carries a display node under a different nodelabel.
753
- const displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
754
- // Touch controller kind comes from which DT nodelabel the emitted adapter
755
- // references (FT6336U on I2C, XPT2046 on the display's SPI bus).
756
- const usesTouch = uses('ft6336u') || uses('touch_');
757
- const usesXpt = uses('xpt2046');
758
- const sensorParts = scanSensorParts(src);
759
- const spiTargetParts = scanSpiTargets(src);
760
- const overlay = generateOverlay(chip, {
761
- // __tc_<bus> matches the shim state block — a begin()-only program
762
- // emits no driver API call but still declares the DT device. A
763
- // constructed sensor is also a bus user (its device handle is the
764
- // only i2c reference a sensor-only program carries).
765
- usesI2c: uses('i2c_') || uses('__tc_i2c') || sensorParts.length > 0,
766
- usesSensor: uses('sensor_') || sensorParts.length > 0,
767
- usesFloatFormat: /%[-0-9.]*[eEfFgG]/.test(src),
768
- sensorParts,
769
- spiTargets: spiTargetParts,
770
- usesSpi: uses('spi_') || uses('__tc_spi') || spiTargetParts.length > 0,
771
- usesUart: uses('__tc_uart'),
772
- usesUsb: uses('__tc_usb'),
773
- usesPwm: uses('pwm_'),
774
- usesAdc: uses('adc_'),
775
- adcReadPins: scanAdcReadPins(src, chip),
776
- dacWritePins: scanDacWritePins(src),
777
- pwmUsedPins: scanPwmUsedPins(src, chip),
778
- i2cUsedInstances: scanUsedBusInstances(src, chip.i2c?.controllers, 'i2c'),
779
- spiUsedInstances: scanUsedBusInstances(src, chip.spi?.controllers, 'spi'),
780
- uartUsedInstances: scanUsedBusInstances(src, chip.uart?.controllers, 'uart'),
781
- // Preferences/FS — same tokens scaffoldZephyrProject scans (the ZMS
782
- // settings_* API + __tc_prefs shim, the __tc_fs mount shim); drive the
783
- // storage-partition synthesis + /chosen settings pointer.
784
- usesPreferences: uses('settings_') || uses('__tc_prefs'),
785
- usesFS: uses('__tc_fs'),
786
- usesWdt: uses('wdt_'),
787
- usesHwtimer: uses('counter_') || uses('__tc_hw'),
788
- usesDisplay,
789
- usesTouch: usesTouch || usesXpt,
790
- touchController: usesXpt ? 'xpt2046' : 'ft6336u',
791
- }, displayProfile);
792
- const overlayDir = join(projectRoot, 'boards');
793
- mkdirSync(overlayDir, { recursive: true });
794
- // prepare() runs before the real target is known — the placeholder name
795
- // never matches `west build -b <board>` (compile rewrites the overlay
796
- // under the actual board's name below).
797
- writeIfChanged(join(overlayDir, 'board.overlay'), overlay);
798
- // Thin SPI targets need an app-local binding: a compatible-less DT node
799
- // generates NO property macros, so SPI_DT_SPEC_GET's spi-max-frequency
800
- // lookup would not exist. The binding has no driver — it exists so
801
- // gen_defines emits the spi properties for the target nodes.
802
- if (spiTargetParts.length > 0) {
803
- const bindingsDir = join(projectRoot, 'dts', 'bindings');
804
- mkdirSync(bindingsDir, { recursive: true });
805
- writeIfChanged(join(bindingsDir, 'cuttlefish,spi-target.yaml'), [
806
- 'description: |',
807
- ' Cuttlefish thin SPITarget (hal/spi-target.ts) — a raw spi_dt_spec',
808
- ' peer. No driver binds this compatible; it exists so devicetree',
809
- ' generation emits the spi properties (spi-max-frequency,',
810
- ' spi-cpol/spi-cpha, reg = the cs-gpios index) that SPI_DT_SPEC_GET',
811
- ' consumes from the generated C++.',
812
- 'compatible: "cuttlefish,spi-target"',
813
- 'include: spi-device.yaml',
814
- '',
815
- ].join('\n'));
816
- }
817
- },
818
-
819
- compile(o: ToolchainOptions): CompileResult {
820
- const projectRoot = projectRootFromOptions(o);
821
- const rawBoard = targetFromOptions(o);
822
-
823
- // Fail fast on an incompatible Zephyr (clear message vs. a cryptic west/
824
- // CMake board error), then normalize the board target for the installed
825
- // version — Zephyr 4.3+ rejects bare multi-core board names, so a stale
826
- // config (esp32s3_devkitc) is rewritten to the qualified form
827
- // (esp32s3_devkitc/esp32s3/procpu). See toolchain/compat.ts.
828
- const zephyrVersion = detectZephyrVersion();
829
- const compat = checkZephyrCompat(zephyrVersion);
830
- if (compat.status === 'out-of-range') {
831
- throw new Error(
832
- `Zephyr ${zephyrVersion} is outside the supported range (${compat.range}) for @typecad/framework-zephyr. ` +
833
- `Set ZEPHYR_BASE to a compatible Zephyr checkout, or install one via '@typecad/zephyr-installer'.`,
834
- );
835
- }
836
- if (compat.status === 'undetectable') {
837
- console.warn(
838
- `! Could not detect the installed Zephyr version (is ZEPHYR_BASE set?). ` +
839
- `Skipping compat check; declared range is ${compat.range}.`,
840
- );
841
- }
842
- const board = resolveBoardTarget(rawBoard, zephyrVersion);
843
-
844
- const debugMode = new ZephyrStrategy().debugMode(board);
845
- const isGdbDebug = o.debug === true && debugMode === 'gdb';
846
- const zc = o.zephyrConfig as Record<string, unknown> | undefined;
847
- const userKconfig = zc?.kconfig as Record<string, string> | undefined;
848
- const configChanged = scaffoldZephyrProject(projectRoot, isGdbDebug, userKconfig, o.psram);
849
-
850
- // Regenerate the DT overlay for the ACTUAL target board. prepare() writes
851
- // it for the default board (the real target is unknown until compile), so
852
- // the <default>.overlay it wrote does not match `west build -b <board>`.
853
- // Zephyr auto-detects boards/<board>.overlay under APPLICATION_CONFIG_DIR.
854
- try {
855
- const chip = chipForBuild(projectRoot, board);
856
- // Custom-board generation: an MCU-only target (no board package) has no
857
- // upstream Zephyr board — generate one under boards/typecad/<name>/ from
858
- // the chip's silicon data. Opt-in via `zephyr.customBoard: true` in
859
- // cuttlefish.config.ts; the board takes its name from the build target.
860
- // Idempotent — regenerated on every compile, before the overlay pass.
861
- if (zc?.customBoard === true) {
862
- const generated = generateCustomBoard(projectRoot, chip, board.split('/')[0]);
863
- if (!generated) {
864
- throw new Error(
865
- `zephyr.customBoard is set, but the resolved chip ('${chip.id}') carries no ` +
866
- `silicon board data. Custom-board generation requires an MCU-only config ` +
867
- `(mcu set, board absent) whose MCU package ships a zephyr block.`,
868
- );
869
- }
870
- }
871
- const srcDir = join(projectRoot, 'src');
872
- let src = '';
873
- try {
874
- for (const name of readdirSync(srcDir)) {
875
- if (name.endsWith('.cpp') || name.endsWith('.c')) {
876
- src += readFileSync(join(srcDir, name), 'utf-8');
877
- }
878
- }
879
- } catch { /* src may not exist */ }
880
- const uses = (t: string): boolean => src.includes(t);
881
- const usesDisplay = uses('display_write') || uses('display_init')
882
- || uses('display_fill_rect') || uses('__tc_display_dev')
883
- || uses('CuttlefishDisplayTarget');
884
- // Derive the display dimensions from the emitted adapter code
885
- // (display_width/height return the profile's w/h). This ensures the DT
886
- // overlay's width/height match the panel the adapter targets, not the
887
- // default profile — critical for drivers like ST7796S that initialize
888
- // the panel geometry from the DT node.
889
- let displayProfile = usesDisplay ? DEFAULT_ZEPHYR_DISPLAY_PROFILE : undefined;
890
- if (usesDisplay) {
891
- const wMatch = src.match(/display_width\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
892
- const hMatch = src.match(/display_height\(\)\s*\{\s*return\s+(\d+)\s*;\s*\}/);
893
- if (wMatch && hMatch) {
894
- displayProfile = {
895
- ...DEFAULT_ZEPHYR_DISPLAY_PROFILE,
896
- width: parseInt(wMatch[1], 10),
897
- height: parseInt(hMatch[1], 10),
898
- };
899
- }
900
- }
901
- // Extract display pin wiring (cs/dc/rst/spiFrequency/spiPins) from the
902
- // config display section so the DT overlay wires the MIPI DBI bridge to
903
- // the correct GPIOs + SPI bus pins.
904
- const dispCfg = o.display as Record<string, unknown> | undefined;
905
- const spiPins = (dispCfg?.spiPins ?? undefined) as
906
- { sck?: unknown; mosi?: unknown; miso?: unknown } | undefined;
907
- const wiring: DisplayWiring | undefined = dispCfg
908
- ? {
909
- cs: typeof dispCfg.cs === 'number' ? dispCfg.cs : undefined,
910
- dc: typeof dispCfg.dc === 'number' ? dispCfg.dc : undefined,
911
- rst: typeof dispCfg.rst === 'number' ? dispCfg.rst : undefined,
912
- spiFrequency: typeof dispCfg.spiFrequency === 'number' ? dispCfg.spiFrequency : undefined,
913
- sck: typeof spiPins?.sck === 'number' ? spiPins.sck : undefined,
914
- mosi: typeof spiPins?.mosi === 'number' ? spiPins.mosi : undefined,
915
- miso: typeof spiPins?.miso === 'number' ? spiPins.miso : undefined,
916
- backlightPin: typeof dispCfg.backlightPin === 'number' ? dispCfg.backlightPin : undefined,
917
- tearingEffectPin: typeof dispCfg.tearingEffectPin === 'number' ? dispCfg.tearingEffectPin : undefined,
918
- }
919
- : undefined;
920
- // Extract touch pin wiring from the config display.touch section so the
921
- // DT overlay wires the bus + touch node. I2C (FT6336U) carries
922
- // irq/resetPin/sda/scl; SPI (XPT2046) carries irq/cs + the calibration
923
- // range the xptek,xpt2046 binding requires.
924
- const touchCfg = dispCfg?.touch as Record<string, unknown> | undefined;
925
- const isXpt = touchCfg?.library === 'XPT2046_Touchscreen';
926
- const touchCal = touchCfg?.calibration as
927
- { xMin?: unknown; xMax?: unknown; yMin?: unknown; yMax?: unknown } | undefined;
928
- const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined);
929
- let touchWiring: TouchWiring | undefined = touchCfg
930
- ? {
931
- controller: isXpt ? 'xpt2046' : 'ft6336u',
932
- irq: num(touchCfg.irq),
933
- resetPin: num(touchCfg.resetPin),
934
- sda: num(touchCfg.sda),
935
- scl: num(touchCfg.scl),
936
- cs: num(touchCfg.cs),
937
- calibration: touchCal
938
- ? {
939
- xMin: num(touchCal.xMin) ?? 0,
940
- xMax: num(touchCal.xMax) ?? 4095,
941
- yMin: num(touchCal.yMin) ?? 0,
942
- yMax: num(touchCal.yMax) ?? 4095,
943
- }
944
- : undefined,
945
- minPressure: num(touchCfg.minPressure),
946
- }
947
- : undefined;
948
- // Touch controller kind for Kconfig (bus driver selection) and the DT
949
- // node shape: from the config when available, else from the DT nodelabel
950
- // the emitted adapter references. Forced onto touchWiring so a source
951
- // scan match without a config section still emits the right node.
952
- const usesXpt = isXpt || uses('xpt2046');
953
- if (usesXpt) {
954
- touchWiring = { controller: 'xpt2046', ...(touchWiring ?? {}) };
955
- }
956
- const overlayDiagnostics: OverlayDiagnostic[] = [];
957
- const sensorParts = scanSensorParts(src);
958
- const spiTargetParts = scanSpiTargets(src);
959
- const overlay = generateOverlay(chip, {
960
- // __tc_<bus> matches the shim state block — a begin()-only program
961
- // emits no driver API call but still declares the DT device. A
962
- // constructed sensor is also a bus user (see scanSensorParts).
963
- usesI2c: uses('i2c_') || uses('__tc_i2c') || sensorParts.length > 0,
964
- usesSensor: uses('sensor_') || sensorParts.length > 0,
965
- usesFloatFormat: /%[-0-9.]*[eEfFgG]/.test(src),
966
- sensorParts,
967
- spiTargets: spiTargetParts,
968
- usesSpi: uses('spi_') || uses('__tc_spi') || spiTargetParts.length > 0,
969
- usesUart: uses('__tc_uart'),
970
- usesUsb: uses('__tc_usb'),
971
- usesPwm: uses('pwm_'),
972
- usesAdc: uses('adc_'),
973
- adcReadPins: scanAdcReadPins(src, chip),
974
- usesDac: uses('dac_') || uses('__tc_dac'),
975
- dacWritePins: scanDacWritePins(src),
976
- pwmUsedPins: scanPwmUsedPins(src, chip),
977
- i2cUsedInstances: scanUsedBusInstances(src, chip.i2c?.controllers, 'i2c'),
978
- spiUsedInstances: scanUsedBusInstances(src, chip.spi?.controllers, 'spi'),
979
- uartUsedInstances: scanUsedBusInstances(src, chip.uart?.controllers, 'uart'),
980
- // Preferences/FS — same tokens scaffoldZephyrProject scans; drive the
981
- // storage-partition synthesis + /chosen settings pointer.
982
- usesPreferences: uses('settings_') || uses('__tc_prefs'),
983
- usesWdt: uses('wdt_'),
984
- usesHwtimer: uses('counter_') || uses('__tc_hw'),
985
- usesFS: uses('__tc_fs'),
986
- usesDisplay,
987
- usesTouch: uses('ft6336u') || uses('touch_') || usesXpt,
988
- touchController: usesXpt ? 'xpt2046' : 'ft6336u',
989
- psram: o.psram,
990
- }, displayProfile, wiring, touchWiring, overlayDiagnostics);
991
- for (const d of overlayDiagnostics) {
992
- console.warn(`overlay: ${d.message}`);
993
- }
994
- const overlayDir = join(projectRoot, 'boards');
995
- mkdirSync(overlayDir, { recursive: true });
996
- // Write the board-specific overlay (the one west loads). Zephyr looks for
997
- // boards/<board_id>.overlay under APPLICATION_CONFIG_DIR — use the bare
998
- // board id (before any hardware-qualifier suffix, e.g. 'esp32_devkitc'
999
- // not the full 'esp32_devkitc/esp32/procpu' target string). Library
1000
- // packages' overlay fragments are appended by the scaffold helper.
1001
- const boardId = board.split('/')[0];
1002
- writeIfChanged(
1003
- join(overlayDir, `${boardId}.overlay`),
1004
- appendLibraryOverlayFragments(overlay, projectRoot),
1005
- );
1006
- // Thin SPI targets need the app-local binding (see the transpile-side
1007
- // write for the rationale): no compatible → no generated spi props →
1008
- // SPI_DT_SPEC_GET's spi-max-frequency lookup does not exist.
1009
- if (spiTargetParts.length > 0) {
1010
- const bindingsDir = join(projectRoot, 'dts', 'bindings');
1011
- mkdirSync(bindingsDir, { recursive: true });
1012
- writeIfChanged(join(bindingsDir, 'cuttlefish,spi-target.yaml'), [
1013
- 'description: |',
1014
- ' Cuttlefish thin SPITarget (hal/spi-target.ts) — a raw spi_dt_spec',
1015
- ' peer. No driver binds this compatible; it exists so devicetree',
1016
- ' generation emits the spi properties (spi-max-frequency,',
1017
- ' spi-cpol/spi-cpha, reg = the cs-gpios index) that SPI_DT_SPEC_GET',
1018
- ' consumes from the generated C++.',
1019
- 'compatible: "cuttlefish,spi-target"',
1020
- 'include: spi-device.yaml',
1021
- '',
1022
- ].join('\n'));
1023
- }
1024
- } catch { /* best-effort overlay regen; the build surfaces DT errors */ }
1025
-
1026
- // Use a stable build dir so incremental builds reuse the Ninja graph.
1027
- // west defaults to <projectRoot>/build.
1028
- const buildDir = join(projectRoot, 'build');
1029
-
1030
- // Reuse the build dir across builds so ninja recompiles only the changed
1031
- // app translation units and re-links — a pristine configure + the
1032
- // ~280-target Zephyr library rebuild costs minutes on Windows
1033
- // (demo-shadcn measures 69s of ninja wall time, 448s of summed compile
1034
- // work, and every build redid all of it). Nuke it only when the generated
1035
- // config changed (prj.conf / CMakeLists content), the one path that must
1036
- // not reuse a cached graph: Zephyr 4.3.99-dev snapshots carry a
1037
- // regression (zephyrproject-rtos/zephyr#104757, fixed by the #104784
1038
- // revert on 2026-03-03, in v4.4+) where re-running CMake after a .config
1039
- // change records an `offsets.h -> offsets.c.obj -> offsets.h` cycle in
1040
- // .ninja_deps, after which every ninja run fails with `dependency cycle`.
1041
- // Plain source edits never reconfigure CMake, so they cannot trigger it —
1042
- // and the retry after the spawn below self-heals any path that still does.
1043
- // Board switches need no nuke here: `west build` is --pristine=auto by
1044
- // default and recreates the dir itself when -b <board> mismatches the
1045
- // cached board.
1046
- if (configChanged) {
1047
- try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
1048
- }
1049
-
1050
- const buildArgs = ['build', '-b', board, '-d', buildDir, projectRoot];
1051
- // Explicitly pass the generated DT overlay. Zephyr's auto-detection of
1052
- // boards/<board>.overlay fails for hardware-qualified targets (e.g.
1053
- // esp32_devkitc/esp32/procpu) because the FILE_SUFFIX matching doesn't
1054
- // resolve — passing -DDTC_OVERLAY_FILE forces it unconditionally.
1055
- const boardId = board.split('/')[0];
1056
- const overlayPath = join(projectRoot, 'boards', `${boardId}.overlay`);
1057
- try {
1058
- if (readFileSync(overlayPath, 'utf-8').length > 0) {
1059
- // CMake parses backslashes as escapes — use forward slashes so the
1060
- // Windows path survives the -D argument intact.
1061
- buildArgs.push('--', `-DDTC_OVERLAY_FILE=${overlayPath.replace(/\\/g, '/')}`);
1062
- }
1063
- } catch { /* no overlay — let Zephyr auto-detect or build without one */ }
1064
- // Append user cmake args from cuttlefish.config.ts zephyr.cmakeArgs.
1065
- const userCmakeArgs = zc?.cmakeArgs as string[] | undefined;
1066
- if (userCmakeArgs && userCmakeArgs.length > 0) {
1067
- if (!buildArgs.includes('--')) buildArgs.push('--');
1068
- buildArgs.push(...userCmakeArgs);
1069
- }
1070
- const inv = westSpawn(
1071
- buildArgs,
1072
- { cwd: projectRoot, encoding: 'utf-8', timeout: BUILD_TIMEOUT_MS },
1073
- );
1074
- let result = spawnSync(inv.command, inv.args, inv.options);
1075
- // Self-heal the Zephyr 4.3.99 dep-cycle regression (see the nuke comment
1076
- // above): when the cached .ninja_deps carries the cycle, ninja aborts with
1077
- // `dependency cycle` before compiling anything. The cycle lives in the
1078
- // build dir, not the sources — one pristine retry clears it and the build
1079
- // proceeds. On fixed Zephyr (>=4.4) this never fires.
1080
- let pristineRetry = false;
1081
- if (result.status !== 0 && isDependencyCycleFailure(combinedSpawnOutput(result))) {
1082
- try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
1083
- result = spawnSync(inv.command, inv.args, inv.options);
1084
- pristineRetry = true;
1085
- }
1086
-
1087
- const stdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
1088
- const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
1089
- const output = stdout + stderr + (pristineRetry
1090
- ? '\n[cuttlefish] dependency cycle detected in the cached build dir — retried with a pristine build'
1091
- : '');
1092
- // Prefix the build log with how west was resolved, for transparency.
1093
- const header = `Using west via ${inv.install.source}` +
1094
- (inv.install.zephyrBase ? ` (ZEPHYR_BASE=${inv.install.zephyrBase})` : '') + '\n';
1095
-
1096
- // After a successful build in gdb mode (--debug on a probe-capable target),
1097
- // write the VS Code launch.json + tasks.json + gdb-script artifacts so F5
1098
- // attaches GDB to the chip's debug probe. Non-fatal on failure — a missing
1099
- // artifact doesn't block the build. Mirrors the deleted framework-esp32
1100
- // toolchain compile() debug-config wiring.
1101
- if (result.status === 0 && isGdbDebug) {
1102
- try {
1103
- const { workspaceRoot, appRel } = resolveDebugLocations(projectRoot);
1104
- writeDebugConfig({
1105
- projectRoot,
1106
- workspaceRoot,
1107
- appRel,
1108
- target: board,
1109
- buildDir,
1110
- sourceMapPath: join(dirname(o.sourcePath), `${basename(o.sourcePath)}.thcppmap.json`),
1111
- });
1112
- } catch (e) {
1113
- console.warn(`[cuttlefish] gdb debug config generation failed: ${(e as Error).message}`);
1114
- }
1115
- }
1116
-
1117
- // As-built snapshot: after a successful build, the resolved devicetree
1118
- // at <buildDir>/zephyr/zephyr.dts carries the board's pinctrl labels —
1119
- // the STABLE name grammar, immune to vendor macro churn. Harvest its
1120
- // routes into .cuttlefish/as-built.json; the next build's board-module
1121
- // generation merges them per-pin over the catalog harvest (build wins,
1122
- // silently when they agree). One-build freshness lag on first setup,
1123
- // self-maintaining after. Best-effort — a missing/unparseable artifact
1124
- // never fails the build.
1125
- if (result.status === 0) {
1126
- try {
1127
- const dtsPath = join(buildDir, 'zephyr', 'zephyr.dts');
1128
- const dtsText = readFileSync(dtsPath, 'utf8');
1129
- const facts = parseZephyrDts(dtsText);
1130
- const total = facts.adc.length + facts.pwm.length + facts.dac.length;
1131
- if (total > 0) {
1132
- // Write beside the project's board module — the .cuttlefish dir the
1133
- // config loader reads from, discovered by walking up to the
1134
- // generated board.json (the scaffold root and the config root are
1135
- // different dirs in the standard layout: src/out vs project root).
1136
- let cfDir = join(projectRoot, '.cuttlefish');
1137
- for (let dir = projectRoot; ; dir = dirname(dir)) {
1138
- if (existsSync(join(dir, '.cuttlefish', 'board.json'))) {
1139
- cfDir = join(dir, '.cuttlefish');
1140
- break;
1141
- }
1142
- const parent = dirname(dir);
1143
- if (parent === dir) break;
1144
- }
1145
- mkdirSync(cfDir, { recursive: true });
1146
- writeIfChanged(join(cfDir, 'as-built.json'), asBuiltJson(board, facts));
1147
- }
1148
- } catch { /* best-effort snapshot — nothing to harvest or unreadable */ }
1149
- }
1150
-
1151
- return {
1152
- success: result.status === 0,
1153
- output: header + output,
1154
- errors: parseCompileErrors(output, o.sourcePath),
1155
- };
1156
- },
1157
-
1158
- upload(o: ToolchainOptions): UploadResult {
1159
- const projectRoot = projectRootFromOptions(o);
1160
- const buildDir = join(projectRoot, 'build');
1161
- const board = targetFromOptions(o);
1162
- const zc = o.zephyrConfig as Record<string, unknown> | undefined;
1163
- const chip = chipForBuild(projectRoot, board);
1164
- const probe = resolveProbeMethod(zc, chip, 'flash');
1165
- if (!probe.ok) {
1166
- return { success: false, output: `-- west flash: ${probe.error}` };
1167
- }
1168
- // BOSSA bootloader boards with touch-reset data: open the app's console
1169
- // port at 1200 baud (the firmware's USB shim reboots into the
1170
- // bootloader), wait for the bootloader identity, and flash THAT port.
1171
- // Falls back to the configured port (manual double-tap) on any failure.
1172
- let flashPort = o.port;
1173
- const flashNotes: string[] = [];
1174
- // The runner this flash will actually use: the explicit choice, else the
1175
- // board's declared default (first probe-method entry). Gates port
1176
- // forwarding and the bossac touch below — board.cmake still resolves
1177
- // the default runner itself.
1178
- const flashRunner = probe.runner ?? chip.probeMethods?.[0]?.runner;
1179
- // Serial-port runners cannot flash without a port; every other runner
1180
- // (probe or USB) proceeds — whether a port is required is the runner's
1181
- // call, not the CLI's blanket gate.
1182
- if (uploadRequiresPort(flashRunner) && !flashPort) {
1183
- return {
1184
- success: false,
1185
- output: `-- upload requires a port for ${flashRunner} flashing. Set --port <port> on the command line (or the CUTTLEFISH_PORT env var).`,
1186
- };
1187
- }
1188
- if (flashRunner === 'bossac' && flashPort && chip.usb?.touchReset) {
1189
- const touch = bossacTouchReset(flashPort, chip.usb.touchReset);
1190
- flashNotes.push(`-- ${touch.note}`);
1191
- if (touch.port) flashPort = touch.port;
1192
- }
1193
- const args = buildFlashArgs(buildDir, probe.runner, flashPort, flashRunner, probe.args);
1194
-
1195
- // openocd flashes go through a dedicated session instead of `west flash`.
1196
- // west's flow has three sequential races that each strand the board: the
1197
- // connect happens against a running (often USB-active) application, the
1198
- // erase precedes the write so the vector table is 0xFFFFFFFF while the
1199
- // RAM algorithm runs (any exception → core LOCKUP at 0xFFFFFFFE, "timeout
1200
- // waiting for algorithm"), and the trailing `reset run` frequently does
1201
- // not reach the core. This session is deterministic end to end: halt at
1202
- // the reset vector (static target for the DAP), mask interrupts for the
1203
- // algorithm (exceptions cannot vector through erased flash), unmask
1204
- // after, and boot the flashed app with a direct SYSRESETREQ. Falls back
1205
- // to `west flash` when the image or session is unavailable.
1206
- let westFallback = true;
1207
- if (probe.runner === 'openocd') {
1208
- const hex = join(buildDir, 'zephyr', 'zephyr.hex');
1209
- if (existsSync(hex)) {
1210
- // Forward slashes + TCL quoting so project paths with spaces work.
1211
- const hexArg = `"${hex.replace(/\\/g, '/')}"`;
1212
- // Target addressing: `cortex_m` is a PER-TARGET subcommand — a bare
1213
- // `cortex_m maskisr on` is an unknown command (a silent no-op inside
1214
- // catch). Resolve the session's target object once and address it.
1215
- // Cortex-M-only, self-gating: on other cores the cortex_m method
1216
- // errors and catch contains it (the plain flash path is safe there
1217
- // without masking — the lockup class is Cortex-M vectoring).
1218
- const flashed = openocdProbeSession(buildDir, zc, chip, [
1219
- 'set _tgt [lindex [target names] 0]',
1220
- 'reset halt',
1221
- 'catch { $_tgt cortex_m maskisr on }',
1222
- `flash write_image erase ${hexArg}`,
1223
- 'catch { $_tgt cortex_m maskisr off }',
1224
- // Boot the flashed app. Cortex-M: a direct SYSRESETREQ via AIRCR —
1225
- // pin-independent, always reaches the core, and clears PRIMASK
1226
- // (so the masked algorithm leaves nothing behind). Other cores:
1227
- // openocd's generic reset run. A halted core STAYS halted across
1228
- // a core-initiated reset (debug halt state survives — that is how
1229
- // reset halt works), so resume it; on a running core resume errors
1230
- // and the catch swallows it.
1231
- 'if {[catch {$_tgt cortex_m maskisr on}] == 0} { $_tgt cortex_m maskisr off; mww 0xE000ED0C 0x05FA0004 } else { reset run }',
1232
- 'sleep 100',
1233
- 'catch { resume }',
1234
- 'sleep 200',
1235
- ]);
1236
- if (flashed) {
1237
- westFallback = false;
1238
- flashNotes.push('-- probe flash ok: halt → masked write → SYSRESETREQ');
1239
- }
1240
- }
1241
- }
1242
-
1243
- let result: ReturnType<typeof spawnSync> | undefined;
1244
- let raw = '';
1245
- let ok = false;
1246
- if (westFallback) {
1247
- const inv = westSpawn(args, {
1248
- cwd: projectRoot,
1249
- encoding: 'utf-8',
1250
- timeout: FLASH_TIMEOUT_MS,
1251
- });
1252
- result = spawnSync(inv.command, inv.args, inv.options);
1253
- const fstdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
1254
- const fstderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
1255
- raw = fstdout + fstderr;
1256
- ok = classifyUploadResult(probe.runner, result.status, raw);
1257
- // A SUCCESSFUL west openocd flash can still leave the core HALTED:
1258
- // west's trailing `reset run` does not reach a core behind an unwired
1259
- // SRST, and the user's only recourse is the NRST button. Boot it from
1260
- // a probe session instead — one core reset (a halted core re-halts at
1261
- // the reset vector; a running core restarts the just-flashed app)
1262
- // plus a resume.
1263
- if (ok && probe.runner === 'openocd') {
1264
- const booted = openocdProbeSession(buildDir, zc, chip, [
1265
- 'set _tgt [lindex [target names] 0]',
1266
- 'if {[catch {$_tgt cortex_m maskisr on}] == 0} { $_tgt cortex_m maskisr off; mww 0xE000ED0C 0x05FA0004 } else { reset run }',
1267
- 'sleep 100',
1268
- 'catch { resume }',
1269
- 'sleep 200',
1270
- ]);
1271
- if (booted) flashNotes.push('-- probe boot ok: SYSRESETREQ → resume');
1272
- }
1273
- // Fallback-path recovery: a failed west flash leaves the core in
1274
- // lockup; a direct SYSRESETREQ clears it so the caller's retry (or a
1275
- // later flash) starts from a clean chip.
1276
- if (!ok && probe.runner === 'openocd') {
1277
- const revived = openocdProbeSession(buildDir, zc, chip, [
1278
- 'init',
1279
- 'set _tgt [lindex [target names] 0]',
1280
- 'if {[catch {$_tgt cortex_m maskisr on}] == 0} { $_tgt cortex_m maskisr off; mww 0xE000ED0C 0x05FA0004 } else { reset run }',
1281
- 'sleep 300',
1282
- ]);
1283
- if (revived) flashNotes.push(revived);
1284
- // Known SWD-failure signatures get a recovery pointer — a board that
1285
- // ignores SWD until power-cycled (low-power state, lockup, a wedged
1286
- // probe) otherwise reads as a toolchain bug.
1287
- if (isTargetSwdFailure(raw)) {
1288
- flashNotes.push(
1289
- '-- target ignored SWD — if a retry fails too: power-cycle the board, replug the probe, or skip SWD entirely (hold BOOT0, tap reset, re-run with --probe dfu)',
1290
- );
1291
- }
1292
- }
1293
- } else {
1294
- ok = true;
1295
- }
1296
- return {
1297
- success: ok,
1298
- output: [...flashNotes, cleanseUploadOutput(probe.runner, westFallback ? result!.status : 0, raw)]
1299
- .filter(Boolean).join('\n'),
1300
- };
1301
- },
1302
-
1303
- monitor(o: ToolchainOptions): void {
1304
- // Serial monitor over USB-CDC. Zephyr does NOT ship a `west serial`
1305
- // subcommand (it's not a real west command — invoking it errors with
1306
- // "unknown command"). The discovered west install's venv carries pyserial,
1307
- // so run its bundled miniterm directly: `python -m serial.tools.miniterm`.
1308
- // That is the same cross-platform terminal pyserial provides in ESP-IDF's
1309
- // idf.py monitor, and it inherits stdio so Ctrl+C exits cleanly.
1310
- if (!o.port) {
1311
- throw new Error(
1312
- 'A serial port is required to monitor. Pass --port <COMx/ttyX>.',
1313
- );
1314
- }
1315
- // ESP32 USB-CDC console runs at 115200 (the Zephyr ESP32 board default).
1316
- // The CLI's generic default of 9600 is wrong for this target; honor an
1317
- // explicit --baud when given, else 115200.
1318
- const baud = o.baud ?? 115200;
1319
- const install = discoverWest();
1320
- const py = install?.pythonExecutable ?? process.env.PYTHON ?? 'python';
1321
- // Reuse west-spawn's env builder (prepends the venv bin dir to PATH so the
1322
- // python we spawn resolves pyserial from the same venv). Falls back to the
1323
- // process env when no install is discovered.
1324
- const env = install ? buildEnv(install) : process.env;
1325
- spawnSync(py, ['-m', 'serial.tools.miniterm', o.port, String(baud)], {
1326
- cwd: projectRootFromOptions(o),
1327
- env,
1328
- stdio: 'inherit',
1329
- });
1330
- },
1331
-
1332
- debug(o: ToolchainOptions): void {
1333
- // Launch an interactive GDB session for the last build. The probe method
1334
- // resolves exactly like flashing (zephyr.probe / zephyr.runner — the same
1335
- // attach session, so the same quirks apply); debug-incapable methods
1336
- // (bootloaders) are rejected with the debug-capable list. west debug
1337
- // resolves the GDB binary from the build dir's CMakeCache — no
1338
- // hand-authored gdbinit needed. Inherits stdio so GDB runs interactively.
1339
- const projectRoot = projectRootFromOptions(o);
1340
- const buildDir = join(projectRoot, 'build');
1341
- const board = targetFromOptions(o);
1342
- const zc = o.zephyrConfig as Record<string, unknown> | undefined;
1343
- const probe = resolveProbeMethod(zc, chipForBuild(projectRoot, board), 'debug');
1344
- if (!probe.ok) {
1345
- console.error(`-- west debug: ${probe.error}`);
1346
- process.exitCode = 1;
1347
- return;
1348
- }
1349
- const debugArgs = ['debug', '-d', buildDir];
1350
- if (probe.runner) debugArgs.push('--runner', probe.runner);
1351
- debugArgs.push(...probe.args);
1352
- const inv = westSpawn(debugArgs, {
1353
- cwd: projectRoot,
1354
- encoding: 'utf-8',
1355
- stdio: 'inherit',
1356
- });
1357
- spawnSync(inv.command, inv.args, inv.options);
1358
- },
1359
- };
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 typecad-hal 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 { spawn, spawnSync } from 'node:child_process';
22
+ import { connect as netConnect } from 'node:net';
23
+ import { basename, delimiter, dirname, join } from 'node:path';
24
+ import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
25
+ import type { ToolchainOptions, CompileResult, UploadResult } from '@typecad/cuttlefish/api/shared';
26
+ import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
27
+ import { scaffoldZephyrProject, writeIfChanged, appendLibraryOverlayFragments } from './scaffold.js';
28
+ import { parseZephyrDts, asBuiltJson } from '../as-built.js';
29
+ import { stampBuildSbom } from '../sbom.js';
30
+ import { westSpawn, buildEnv } from './west-spawn.js';
31
+ import { discoverWest } from './west-discover.js';
32
+ import { writeDebugConfig, resolveDebugLocations, debugArtifactsNeedRewrite, DEBUG_SERVER_PORT, DEBUG_SERVER_TCL_PORT } from './debug-config.js';
33
+ import { readRunnersFacts } from './runners.js';
34
+ import { bossacTouchReset } from './bossac-touch.js';
35
+ import { ZephyrStrategy } from '../strategy.js';
36
+ import { generateOverlay, type DisplayWiring, type TouchWiring, type OverlayDiagnostic } from '../dt-config/overlay.js';
37
+ import { generateCustomBoard } from '../dt-config/custom-board.js';
38
+ import { NO_BOARD_CHIP } from '../chips/index.js';
39
+ import { resolveChipFromBoard } from '../chips/resolve.js';
40
+ import type { ZephyrChipDescriptor } from '../chips/types.js';
41
+ import { pwmDtAliasToken } from '../lowering/pwm.js';
42
+ import { detectZephyrVersion, checkZephyrCompat, resolveBoardTarget } from './compat.js';
43
+ import { DEFAULT_ZEPHYR_DISPLAY_PROFILE, profileFromEmittedSource } from '../display/profiles.js';
44
+
45
+ /**
46
+ * Resolve the chip for a build the same way the strategy does at emit time —
47
+ * from the board constants the transpile persisted next to the emitted
48
+ * source (`board-constants.json`). There is no registry fallback: a build
49
+ * whose constants did not persist stays NO_BOARD_CHIP, exactly like the
50
+ * emit-time path.
51
+ */
52
+ function chipForBuild(projectRoot: string, board: string): ZephyrChipDescriptor {
53
+ try {
54
+ // The transpile writes the constants into the emit outDir, which is
55
+ // <projectRoot>/src for the standard layout (basename 'src' collapsed by
56
+ // projectRootFromOptions); check both locations.
57
+ const bcPath = [join(projectRoot, 'src', 'board-constants.json'), join(projectRoot, 'board-constants.json')]
58
+ .find(p => existsSync(p));
59
+ if (bcPath) {
60
+ const raw = JSON.parse(readFileSync(bcPath, 'utf8')) as Record<string, string | number | boolean>;
61
+ const bc = new Map(Object.entries(raw));
62
+ const fromBoard = resolveChipFromBoard(bc);
63
+ if (fromBoard) return fromBoard;
64
+ }
65
+ } catch { /* constants unreadable — no board resolved */ }
66
+ return NO_BOARD_CHIP;
67
+ }
68
+
69
+ /**
70
+ * HAL pins the emitted sources read via adc.* — scanned from the emitted
71
+ * `__tc_adc<N>_setup()` call sites (N = channel index, mapped back to the HAL
72
+ * pin via the chip descriptor). Feeds the overlay's ADC pinctrl rewrite: on
73
+ * SoCs that mux ADC pads via pinctrl (STM32), only the read channels are
74
+ * switched to analog mode.
75
+ */
76
+ function scanAdcReadPins(src: string, chip: ZephyrChipDescriptor): number[] {
77
+ const pins: number[] = [];
78
+ // Two used-signal forms:
79
+ // - `__tc_adc<N>_setup()` / `__tc_adc_<ctrl>_<N>_setup()` CALL SITES
80
+ // (empty parens — the adcInitLines definitions have `(void)` and would
81
+ // otherwise mark every descriptor channel as used).
82
+ // - `__tc_adct<pin>_done` lazy-guard vars — the thin-ADC read lowering's
83
+ // inline setup (families without pinctrl groups, e.g. ESP32 SARADC,
84
+ // never emit the setup-function form at all).
85
+ // Channel indices are unique per CONTROLLER, so the labeled form resolves
86
+ // (controller, channel) before mapping back to the HAL pin.
87
+ for (const m of src.matchAll(/__tc_adc(?:(\w+?)_)?(\d+)_setup\(\)/g)) {
88
+ const ch = Number(m[2]);
89
+ const label = m[1];
90
+ const c = chip.adc?.channels.find(
91
+ (x) => x.channel === ch && (x.controller ?? chip.adc?.nodeLabel) === (label ?? chip.adc?.nodeLabel),
92
+ );
93
+ if (c && !pins.includes(c.pin)) pins.push(c.pin);
94
+ }
95
+ for (const m of src.matchAll(/__tc_adct(\d+)_done/g)) {
96
+ const pin = Number(m[1]);
97
+ if (chip.adc?.channels.some((x) => x.pin === pin) && !pins.includes(pin)) pins.push(pin);
98
+ }
99
+ return pins;
100
+ }
101
+
102
+ /**
103
+ * HAL pins the emitted sources drive with dac.* — the DAC lowering's lazy
104
+ * per-pin setup guard is `__tc_dact<pin>_done` (lowering/dac.ts), so
105
+ * var-presence is the authoritative used-signal. Feeds the overlay's DAC
106
+ * pinctrl gating (same pattern as scanAdcReadPins).
107
+ */
108
+ function scanDacWritePins(src: string): number[] {
109
+ const pins: number[] = [];
110
+ for (const m of src.matchAll(/__tc_dact(\d+)_done/g)) {
111
+ const pin = Number(m[1]);
112
+ if (!pins.includes(pin)) pins.push(pin);
113
+ }
114
+ return pins;
115
+ }
116
+
117
+ /**
118
+ * HAL pins the emitted sources drive with pwm.* — the emitted source
119
+ * references each used spec as `__tc_pwm_<alias token>` (pwmVarName in
120
+ * lowering/pwm.ts), and the lowering only emits specs for driven pins, so
121
+ * var-presence is the authoritative signal. Feeds the overlay's per-pin
122
+ * pwm-leds gating (no dead DT channels).
123
+ */
124
+ /**
125
+ * Inline-override markers (the escape hatch): the adc/pwm lowerings emit
126
+ * `/* cuttlefish-user-facts: <kind> pin=N [device=X] [pinctrl=P] channel=C *​/`
127
+ * comments when a construction carries routing overrides. Merged into the
128
+ * chip so the overlay synthesis + used-pin scans treat them as facts —
129
+ * the transpiler cannot create DT nodes, but this regen can.
130
+ */
131
+ function applyUserFactMarkers(chip: ZephyrChipDescriptor, src: string): ZephyrChipDescriptor {
132
+ const adcAdds: { pin: number; channel: number; controller?: string; pinctrl?: string }[] = [];
133
+ const pwmAdds: { pin: number; controller: string; channel: number }[] = [];
134
+ for (const m of src.matchAll(/\/\* cuttlefish-user-facts: (adc|pwm) ([^*]*?) \*\//g)) {
135
+ const kind = m[1];
136
+ const fields = new Map<string, string>();
137
+ for (const kv of m[2]!.split(/\s+/).filter(Boolean)) {
138
+ const eq = kv.indexOf('=');
139
+ if (eq > 0) fields.set(kv.slice(0, eq), kv.slice(eq + 1));
140
+ }
141
+ const pin = Number(fields.get('pin'));
142
+ const channel = Number(fields.get('channel'));
143
+ if (!Number.isFinite(pin) || !Number.isFinite(channel)) continue;
144
+ if (kind === 'adc') {
145
+ adcAdds.push({
146
+ pin,
147
+ channel,
148
+ ...(fields.get('device') ? { controller: fields.get('device') } : {}),
149
+ ...(fields.get('pinctrl') ? { pinctrl: fields.get('pinctrl') } : {}),
150
+ });
151
+ } else if (fields.get('controller')) {
152
+ pwmAdds.push({ pin, channel, controller: fields.get('controller')! });
153
+ }
154
+ }
155
+ if (adcAdds.length === 0 && pwmAdds.length === 0) return chip;
156
+ const adc = chip.adc
157
+ ? chip.adc
158
+ : { nodeLabel: adcAdds.find((a) => !a.controller)?.controller ?? 'adc', resolution: 12, vrefMv: 3000, channels: [] };
159
+ const adcChannels = [...adc.channels];
160
+ for (const a of adcAdds) {
161
+ const existing = adcChannels.findIndex((c) => c.pin === a.pin);
162
+ if (existing >= 0) adcChannels.splice(existing, 1);
163
+ adcChannels.push({ pin: a.pin, channel: a.channel, ...(a.controller ? { controller: a.controller } : {}), ...(a.pinctrl ? { pinctrl: a.pinctrl } : {}) });
164
+ }
165
+ const pwmSpecs = [...(chip.pwm?.specs ?? [])];
166
+ for (const p of pwmAdds) {
167
+ const existing = pwmSpecs.findIndex((s) => s.pin === p.pin);
168
+ if (existing >= 0) pwmSpecs.splice(existing, 1);
169
+ pwmSpecs.push({ pin: p.pin, controller: p.controller, channel: p.channel });
170
+ }
171
+ return {
172
+ ...chip,
173
+ adc: { ...adc, channels: adcChannels },
174
+ pwm: { ...(chip.pwm ?? { specs: [] }), specs: pwmSpecs },
175
+ };
176
+ }
177
+
178
+ function scanPwmUsedPins(src: string, chip: ZephyrChipDescriptor): number[] { const pins = (chip.pwm?.specs ?? [])
179
+ .filter((s) => src.includes(`__tc_pwm_${pwmDtAliasToken(s)}`))
180
+ .map((s) => s.pin);
181
+ // Matrix pins (ESP32 LEDC) have no static specs — recover the driven pins
182
+ // from the same alias-var presence signal (`__tc_pwm_tc_pwm<N>`), keeping
183
+ // only pins the descriptor's matrix allows (the regex grabs the full
184
+ // number, so pin 4 never matches a reference to pin 45).
185
+ const matrix = chip.pwm?.matrix;
186
+ if (matrix) {
187
+ const present = new Set<number>();
188
+ for (const match of src.matchAll(/__tc_pwm_tc_pwm(\d+)/g)) {
189
+ present.add(Number(match[1]));
190
+ }
191
+ for (const pin of matrix.pins) {
192
+ if (present.has(pin)) pins.push(pin);
193
+ }
194
+ }
195
+ return pins;
196
+ }
197
+
198
+ export function scanMatrix(src: string): { rows: number[]; cols: number[] } | undefined {
199
+ // The on_key lowering's comment marker carries the construction lists.
200
+ const m = src.match(/cuttlefish-matrix: rows=([\d,]*) cols=([\d,]*)/);
201
+ if (!m) return undefined;
202
+ const rows = m[1] ? m[1].split(',').filter(Boolean).map(Number) : [];
203
+ const cols = m[2] ? m[2].split(',').filter(Boolean).map(Number) : [];
204
+ if (rows.length === 0 || cols.length === 0) return undefined;
205
+ return { rows, cols };
206
+ }
207
+
208
+ export function scanHid(src: string): 'keyboard' | 'mouse' | undefined {
209
+ // The shim's report buffer names are the presence signal.
210
+ if (src.includes('__tc_mouse_report')) return 'mouse';
211
+ if (src.includes('__tc_kb_report')) return 'keyboard';
212
+ return undefined;
213
+ }
214
+
215
+ export function scanStrips(src: string): Array<{ index: number; count: number }> {
216
+ // The shim's buffer declaration is the presence signal:
217
+ // `static struct led_rgb __tc_strip<N>_buf[<COUNT>];`
218
+ const out = new Map<number, number>();
219
+ for (const m of src.matchAll(/__tc_strip(\d+)_buf\[(\d+)\]/g)) {
220
+ const idx = Number(m[1]);
221
+ const count = Number(m[2]);
222
+ out.set(idx, Math.max(out.get(idx) ?? 0, count));
223
+ }
224
+ return [...out.entries()].map(([index, count]) => ({ index, count }));
225
+ }
226
+
227
+ /**
228
+ * Bus controller indexes the emitted sources actually reference — the shim
229
+ * declares one `__tc_<bus><N>_dev` state block per used instance (gated by
230
+ * collectUsedBusIndices at transpile time), so var-presence is the
231
+ * authoritative signal. The overlay enables only these controllers: an
232
+ * enabled-but-unused one claims its default pins (i2c0's GP4/GP5 on the
233
+ * Pico) which a program driving the OTHER controller may want as GPIO.
234
+ * Empty list (no state blocks — e.g. display/touch composites that use the
235
+ * driver API directly) means "no signal"; the caller then passes undefined
236
+ * so the overlay enables every declared controller, preserving old behavior.
237
+ */
238
+ function scanUsedBusInstances(
239
+ src: string,
240
+ controllers: readonly { nodeLabel: string }[] | undefined,
241
+ bus: 'i2c' | 'spi' | 'uart',
242
+ ): number[] | undefined {
243
+ if (!controllers) return undefined;
244
+ const used: number[] = [];
245
+ for (let i = 0; i < controllers.length; i++) {
246
+ if (src.includes(`__tc_${bus}${i}_dev`)) used.push(i);
247
+ }
248
+ return used.length > 0 ? used : undefined;
249
+ }
250
+
251
+ /**
252
+ * Distinct DT-bound sensors the emitted sources reference — the lowering's
253
+ * state blocks name each one `__tc_sensor_<part>_i2c<N>_0x<addr>_dev`
254
+ * (lowering/sensor.ts sensorNames), so var-presence is the authoritative
255
+ * signal. Feeds the overlay's DT child nodes; the part group is greedy so a
256
+ * compatible containing '_i2c<N>_' still resolves to the longest part match.
257
+ */
258
+ export interface ScannedSensorPart {
259
+ part: string; busIndex: number; port: number; busKind: 'i2c' | 'spi' | 'w1';
260
+ spiHz: number; spiMode: number; alertPin: number;
261
+ /** 1-Wire parts: the converter resolution in bits. */
262
+ resolution: number;
263
+ }
264
+
265
+ export interface ScannedSpiTarget {
266
+ busIndex: number; cs: number; hz: number; mode: number;
267
+ }
268
+
269
+ /** Scan the emitted source for thin SPI targets (hal/spi-target.ts): the
270
+ * spi_dt_spec state block's tc-spit-cfg comment carries the construction
271
+ * facts, the same channel tc-sensor-cfg uses. */
272
+ export function scanSpiTargets(src: string): ScannedSpiTarget[] {
273
+ const out = new Map<string, ScannedSpiTarget>();
274
+ for (const m of src.matchAll(/tc-spit-cfg: tc_spit_spi(\d+)_cs(\d+) hz=(\d+) mode=(\d+)/g)) {
275
+ const ref: ScannedSpiTarget = { busIndex: parseInt(m[1], 10), cs: parseInt(m[2], 10), hz: parseInt(m[3], 10), mode: parseInt(m[4], 10) };
276
+ out.set(`${ref.busIndex}|${ref.cs}`, ref);
277
+ }
278
+ return [...out.values()];
279
+ }
280
+
281
+ export function scanSensorParts(src: string): ScannedSensorPart[] {
282
+ const out = new Map<string, ScannedSensorPart>();
283
+ // 1-Wire sensors: the stem carries the data pin (w1 master's GPIO).
284
+ for (const m of src.matchAll(/__tc_sensor_([a-z0-9_]+)_w1_p(\d+)_dev\b/g)) {
285
+ out.set(`${m[1]}|w1|${m[2]}`, { part: m[1], busIndex: 0, port: parseInt(m[2], 10), busKind: 'w1', spiHz: 0, spiMode: 0, alertPin: -1, resolution: 12 });
286
+ }
287
+ for (const m of src.matchAll(/__tc_sensor_([a-z0-9_]+)_(i2c|spi)(\d+)_(0x[0-9a-f]+|cs[0-9]+)_dev\b/g)) {
288
+ const port = m[4].startsWith('0x') ? parseInt(m[4], 16) : parseInt(m[4].slice(2), 10);
289
+ const ref: ScannedSensorPart = { part: m[1], busIndex: parseInt(m[3], 10), port, busKind: m[2] as 'i2c' | 'spi', spiHz: 0, spiMode: 0, alertPin: -1, resolution: 12 };
290
+ out.set(`${ref.part}|${ref.busKind}${ref.busIndex}|${ref.port}`, ref);
291
+ }
292
+ // Construction facts ride the state block's config comment — merge by
293
+ // nodelabel so the scanner stays the single source for the overlay.
294
+ for (const m of src.matchAll(/tc-sensor-cfg: tc_([a-z0-9_]+)_w1_p(\d+) res=(\d+)/g)) {
295
+ const key = `${m[1]}|w1|${m[2]}`;
296
+ const existing = out.get(key);
297
+ if (existing) existing.resolution = parseInt(m[3], 10);
298
+ }
299
+ for (const m of src.matchAll(/tc-sensor-cfg: tc_([a-z0-9_]+)_(i2c|spi)(\d+)_(0x[0-9a-f]+|cs[0-9]+) hz=(\d+) mode=(\d+) alert=(-?\d+)/g)) {
300
+ const port = m[4].startsWith('0x') ? parseInt(m[4], 16) : parseInt(m[4].slice(2), 10);
301
+ const key = `${m[1]}|${m[2]}${m[3]}|${port}`;
302
+ const existing = out.get(key);
303
+ if (existing) {
304
+ existing.spiHz = parseInt(m[5], 10);
305
+ existing.spiMode = parseInt(m[6], 10);
306
+ existing.alertPin = parseInt(m[7], 10);
307
+ }
308
+ }
309
+ return [...out.values()];
310
+ }
311
+
312
+ function targetFromOptions(o: ToolchainOptions): string {
313
+ // The cuttlefish CLI populates ToolchainOptions.buildTarget from the
314
+ // config's board: (frameworkData.buildTarget for board-less projects).
315
+ // Accept frameworkData.target as an alias. No silent default: building for
316
+ // a wrong hard-coded board is the split-brain trap.
317
+ const fcTarget = (o.frameworkConfig?.target as string | undefined);
318
+ const board = (o.buildTarget as string | undefined) ?? fcTarget;
319
+ if (!board) {
320
+ throw new Error(
321
+ 'No build target: set board: in typecad-hal.config.ts (or frameworkData.buildTarget for custom-board projects).',
322
+ );
323
+ }
324
+ return board;
325
+ }
326
+
327
+ /**
328
+ * Derive the Zephyr project root from the typecad-hal-emitted source path.
329
+ *
330
+ * Cuttlefish emits `src/main.cpp` under the output dir. The CLI passes
331
+ * `sourcePath` = full path to `main.cpp` and `outputDir` = its parent (`src/`).
332
+ * For Zephyr, the project root is the parent of `src/` — one level above
333
+ * `outputDir`. Detect that shape and adjust; otherwise fall back to `outputDir`.
334
+ */
335
+ export function projectRootFromOptions(o: ToolchainOptions): string {
336
+ const outDir = o.outputDir;
337
+ if (basename(outDir) === 'src') {
338
+ return dirname(outDir);
339
+ }
340
+ return outDir;
341
+ }
342
+
343
+ /**
344
+ * west build timeout. Zephyr's first build fetches the toolchain modules and
345
+ * configures CMake/Ninja, which can take several minutes; allow generous headroom.
346
+ */
347
+ const BUILD_TIMEOUT_MS = 600_000;
348
+ const FLASH_TIMEOUT_MS = 120_000;
349
+
350
+ /**
351
+ * Build the `west flash` argument list for a board.
352
+ *
353
+ * Runner selection: each board's board.cmake declares a sensible default flash
354
+ * runner for its hardware (xiao_ble → nrfutil, esp32* → esptool), and `west
355
+ * flash` resolves it automatically. The framework only intervenes where the
356
+ * board default needs an argument it can't infer:
357
+ * - An explicit `zephyr.runner` (from typecad-hal.config.ts) always wins.
358
+ * - ESP32 boards forward the port via `--esp-device` (esptool reads the
359
+ * device from it); board.cmake still picks the runner.
360
+ * - Every other board trusts the board.cmake default. Previously this forced
361
+ * `--runner nrfjprog` for every non-ESP32 target, which broke boards whose
362
+ * default is not nrfjprog (xiao_ble defaults to nrfutil) and required
363
+ * Nordic J-Link tools that a USB-bootloader board does not have.
364
+ *
365
+ * Exported (pure) so the runner-selection contract is unit-testable without
366
+ * spawning west.
367
+ */
368
+ /**
369
+ * Resolve HOW this build attaches to the board for flashing OR debugging:
370
+ * the friendly `zephyr.probe` id from the board's probeMethods table (quirks
371
+ * included), or the raw `zephyr.runner` escape hatch. Exported (pure) so the
372
+ * selection contract is unit-testable without spawning west.
373
+ *
374
+ * Rules:
375
+ * - `probe` + `runner` together is an error (two ways of saying it — pick one).
376
+ * - An unknown `probe` id is an error listing what the board supports; a board
377
+ * with no probeMethods table gets a hint to use `runner` directly.
378
+ * - purpose 'debug': the chosen method must be debug-capable (`debug` is not
379
+ * false — a bootloader is not a debugger). Non-capable or unknown ids list
380
+ * the debug-capable methods.
381
+ * - User `runnerArgs` are appended AFTER the method's args, so they can
382
+ * override the method's baked-in flags (argparse takes the last value).
383
+ */
384
+ export type ProbeResolution =
385
+ | { ok: true; runner?: string; args: string[] }
386
+ | { ok: false; error: string };
387
+
388
+ /**
389
+ * The board a cached build dir was configured for (CMakeCache.txt's
390
+ * BOARD:STRING — the exact value passed to `west build -b`), or undefined
391
+ * when no cache exists. compile() compares it against the requested board
392
+ * and nukes the dir on mismatch: `west build`'s --pristine=auto covers
393
+ * cmake/config churn, NOT a board switch — west aborts with "refusing to
394
+ * proceed without --force", and cuttlefish doesn't forward that flag.
395
+ */
396
+ export function cachedBuildBoard(buildDir: string): string | undefined {
397
+ try {
398
+ return readFileSync(join(buildDir, 'CMakeCache.txt'), 'utf-8')
399
+ .match(/^BOARD:STRING=(.+)$/m)?.[1]?.trim() || undefined;
400
+ } catch {
401
+ return undefined; // no build dir / unreadable cache — treat as fresh
402
+ }
403
+ }
404
+
405
+ export function resolveProbeMethod(
406
+ zc: Record<string, unknown> | undefined,
407
+ chip: ZephyrChipDescriptor,
408
+ purpose: 'flash' | 'debug' = 'flash',
409
+ ): ProbeResolution {
410
+ const probe = zc?.probe as string | undefined;
411
+ const runner = zc?.runner as string | undefined;
412
+ const userArgs = (zc?.runnerArgs as string[] | undefined) ?? [];
413
+
414
+ if (probe && runner) {
415
+ return {
416
+ ok: false,
417
+ error:
418
+ `typecad-hal.config.ts sets both zephyr.probe ('${probe}') and zephyr.runner ('${runner}'). ` +
419
+ `They are two ways to choose the probe method — remove one.`,
420
+ };
421
+ }
422
+
423
+ if (probe) {
424
+ const methods = chip.probeMethods ?? [];
425
+ const method = methods.find((m) => m.id === probe);
426
+ if (!method) {
427
+ const listAll = methods
428
+ .map((m) => `${m.id} (${m.runner}${m.description ? ` — ${m.description}` : ''})`)
429
+ .join('; ');
430
+ return {
431
+ ok: false,
432
+ error: methods.length > 0
433
+ ? `Unknown probe method '${probe}' for ${chip.id}. Supported: ${listAll}.`
434
+ : `This board (${chip.id}) ships no probe-method table, so 'zephyr.probe' cannot resolve '${probe}'. ` +
435
+ `Use the raw 'zephyr.runner' field instead (run 'west flash --context' in the build dir for options).`,
436
+ };
437
+ }
438
+ if (purpose === 'debug' && method.debug === false) {
439
+ const debuggable = methods.filter((m) => m.debug !== false).map((m) => m.id).join(', ');
440
+ return {
441
+ ok: false,
442
+ error:
443
+ `The '${probe}' method cannot debug ${chip.id} — a bootloader is not a debugger. ` +
444
+ `Debug-capable methods: ${debuggable || '(none — this board needs an external probe)'}.`,
445
+ };
446
+ }
447
+ return {
448
+ ok: true,
449
+ runner: method.runner,
450
+ args: [...(method.args ?? []), ...userArgs],
451
+ };
452
+ }
453
+
454
+ return { ok: true, runner, args: userArgs };
455
+ }
456
+
457
+ /**
458
+ * Run an openocd session against the board's probe config — the shared
459
+ * engine for the pre-flash quiesce and the post-flash SYSRESETREQ (see the
460
+ * call sites in upload()). The config is the probe method's verbatim
461
+ * debugCfg from the board catalog (the same lines `west debug` uses);
462
+ * `commands` are appended after `-f <cfg> -c init`. Returns a flash note on
463
+ * success, undefined when skipped or failed (best-effort by design).
464
+ */
465
+ /**
466
+ * Resolve the openocd binary (plus script search dirs) for the probe session.
467
+ *
468
+ * Resolution order mirrors how west's own openocd runner finds the binary, so
469
+ * the session and `west flash` drive the SAME openocd:
470
+ * 1. $OPENOCD — the variable Zephyr's CMake reads into the build cache.
471
+ * 2. A Zephyr-SDK-hosted install ($ZEPHYR_SDK_INSTALL_DIR, else the
472
+ * discovered west install's SDK). Layout differs by SDK generation —
473
+ * hosttools/openocd/share/openocd/scripts vs hosttools/openocd/scripts —
474
+ * so both script dirs are collected.
475
+ * 3. PATH — Linux distro / conda / micromamba installs (a micromamba-env
476
+ * openocd is the common Linux setup; the SDK layout check alone made the
477
+ * deterministic probe session unreachable there, silently dropping every
478
+ * Linux flash to the racy `west flash` fallback).
479
+ *
480
+ * A non-SDK binary resolves with no explicit -s dirs: it finds its own
481
+ * interface/target scripts via its compiled-in search path. Returns undefined
482
+ * when no openocd can be found (the caller then uses the west fallback).
483
+ *
484
+ * Exported (pure) so the resolution contract is unit-testable without
485
+ * spawning openocd.
486
+ */
487
+ export interface SessionOpenOcd {
488
+ readonly exe: string;
489
+ readonly searchDirs: readonly string[];
490
+ }
491
+
492
+ export function resolveSessionOpenOcd(
493
+ env: {
494
+ OPENOCD?: string | undefined;
495
+ ZEPHYR_SDK_INSTALL_DIR?: string | undefined;
496
+ PATH?: string | undefined;
497
+ } = process.env,
498
+ sdkInstallDir?: string,
499
+ ): SessionOpenOcd | undefined {
500
+ const exeName = process.platform === 'win32' ? 'openocd.exe' : 'openocd';
501
+ if (env.OPENOCD && existsSync(env.OPENOCD)) {
502
+ return { exe: env.OPENOCD, searchDirs: [] };
503
+ }
504
+ const sdkRoot = env.ZEPHYR_SDK_INSTALL_DIR || sdkInstallDir;
505
+ if (sdkRoot) {
506
+ const exe = join(sdkRoot, 'hosttools', 'openocd', 'bin', exeName);
507
+ if (existsSync(exe)) {
508
+ const searchDirs = [
509
+ join(sdkRoot, 'hosttools', 'openocd', 'share', 'openocd', 'scripts'),
510
+ join(sdkRoot, 'hosttools', 'openocd', 'scripts'),
511
+ ].filter((d) => existsSync(d));
512
+ return { exe, searchDirs };
513
+ }
514
+ }
515
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
516
+ if (!dir) continue;
517
+ const candidate = join(dir, exeName);
518
+ if (existsSync(candidate)) return { exe: candidate, searchDirs: [] };
519
+ }
520
+ return undefined;
521
+ }
522
+
523
+ function openocdProbeSession(
524
+ buildDir: string,
525
+ zc: Record<string, unknown> | undefined,
526
+ chip: ZephyrChipDescriptor,
527
+ commands: readonly string[],
528
+ ): string | undefined {
529
+ // Config resolution — two sources, in order:
530
+ // 1. The named probe method's verbatim debugCfg from the board catalog
531
+ // (written to a temp cfg), when zephyr.probe names a method that has
532
+ // one.
533
+ // 2. The board's own support/openocd.cfg in the Zephyr tree — the exact
534
+ // config `west flash` resolves for the openocd runner. This covers
535
+ // raw `zephyr.runner: 'openocd'` (no named probe) and probe methods
536
+ // that ship no debugCfg of their own.
537
+ const probeId = zc?.probe as string | undefined;
538
+ const method = chip.probeMethods?.find((m) => m.id === probeId);
539
+ const cfgLines = method?.debugCfg;
540
+ // Session reset policy: `reset_config none`. The session's resets are
541
+ // core-domain by design — vector-catch halt before the flash write,
542
+ // SYSRESETREQ to boot — so they must not depend on the SRST pin. Boards
543
+ // like the WeAct Black Pill don't break NRST out at all: under the board
544
+ // cfg's `srst_only`, every `reset` asserts a pin that reaches nothing
545
+ // (the target never resets, `reset halt` catches the core mid-app in
546
+ // dirty state and the flash algorithm times out) while the probe's
547
+ // floating SRST sense reports phantom "external reset detected" events
548
+ // that leave the session's halt state inconsistent. Method-declared west
549
+ // quirks (`--cmd-pre-init=…`) are appended after and override the
550
+ // default for boards whose facts carry one.
551
+ const preInit = [
552
+ 'reset_config none',
553
+ ...(method?.args ?? [])
554
+ .filter((a) => a.startsWith('--cmd-pre-init='))
555
+ .map((a) => a.slice('--cmd-pre-init='.length)),
556
+ ];
557
+
558
+ const install = discoverWest();
559
+ const sessionOpenOcd = resolveSessionOpenOcd(process.env, install?.sdkInstallDir);
560
+ if (!sessionOpenOcd) return undefined;
561
+ const openocdExe = sessionOpenOcd.exe;
562
+ const searchArgs = sessionOpenOcd.searchDirs.flatMap((d) => ['-s', d] as [string, string]);
563
+
564
+ let cfgArgs: string[] | undefined;
565
+ let sessionCfg: string | undefined;
566
+ if (cfgLines && cfgLines.length > 0) {
567
+ sessionCfg = join(buildDir, 'typecad-hal-probe.cfg');
568
+ } else {
569
+ // The board target's qualifier ('blackpill_f411ce/stm32f411xe' →
570
+ // 'blackpill_f411ce') identifies the board dir; the vendor segment is
571
+ // not part of the target, so probe the boards/ tree for it.
572
+ const zephyrBase = process.env.ZEPHYR_BASE || install?.zephyrBase;
573
+ const boardDir = (chip.id ?? '').split('/')[0];
574
+ if (!zephyrBase || !boardDir) return undefined;
575
+ const boardsRoot = join(zephyrBase, 'boards');
576
+ let supportCfg: string | undefined;
577
+ try {
578
+ for (const vendor of readdirSync(boardsRoot)) {
579
+ const candidate = join(boardsRoot, vendor, boardDir, 'support', 'openocd.cfg');
580
+ if (existsSync(candidate)) { supportCfg = candidate; break; }
581
+ }
582
+ } catch {
583
+ return undefined;
584
+ }
585
+ if (!supportCfg) return undefined;
586
+ cfgArgs = ['-s', dirname(supportCfg), '-f', supportCfg];
587
+ }
588
+
589
+ try {
590
+ mkdirSync(buildDir, { recursive: true });
591
+ if (sessionCfg) {
592
+ writeFileSync(sessionCfg, cfgLines!.join('\n') + '\n', 'utf-8');
593
+ cfgArgs = ['-f', sessionCfg];
594
+ }
595
+ const res = spawnSync(openocdExe, [
596
+ ...searchArgs, ...cfgArgs!,
597
+ // Pre-init TCL AFTER the cfg (overrides its reset_config) and BEFORE
598
+ // init — the same position west gives --cmd-pre-init.
599
+ ...preInit.map((c) => ['-c', c] as [string, string]).flat(),
600
+ '-c', 'init',
601
+ ...commands.map((c) => ['-c', c]).flat(),
602
+ '-c', 'shutdown',
603
+ ], {
604
+ cwd: buildDir,
605
+ encoding: 'utf-8' as const,
606
+ timeout: 20_000,
607
+ });
608
+ return res.status === 0 ? `-- probe session ok: ${commands.join('; ')}` : undefined;
609
+ } catch {
610
+ return undefined;
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Whether a flash runner carries the upload over a serial port. Runner-gated,
616
+ * never board-name-gated: esptool and bossac are the only runners
617
+ * `buildFlashArgs` forwards `--port` to, so they are the only ones that
618
+ * cannot flash without one. Probe runners (openocd, jlink) and USB flows
619
+ * (dfu-util, uf2 mass storage) need no port — a missing `--port` must not
620
+ * block them.
621
+ *
622
+ * Exported (pure) so the port-requirement contract is unit-testable without
623
+ * spawning west.
624
+ */
625
+ export function uploadRequiresPort(runner: string | undefined): boolean {
626
+ return runner === 'esptool' || runner === 'bossac';
627
+ }
628
+
629
+ export function buildFlashArgs(
630
+ buildDir: string,
631
+ userRunner: string | undefined,
632
+ port: string | undefined,
633
+ flashRunner?: string,
634
+ runnerArgs?: readonly string[],
635
+ ): string[] {
636
+ // flashRunner is the runner the flash will actually use — the explicit
637
+ // zephyr.runner when set, else the board's declared default from its probe
638
+ // table. It gates the esptool port forwarding below; only an EXPLICIT
639
+ // userRunner forces west's --runner (board defaults stay board.cmake's
640
+ // choice). Runner-gated, never board-name-gated: any board whose flash
641
+ // runs esptool gets the same forwarding.
642
+ const args = ['flash', '-d', buildDir];
643
+ if (userRunner) {
644
+ args.push('--runner', userRunner);
645
+ }
646
+ if (port && flashRunner === 'esptool') {
647
+ args.push('--esp-device', port);
648
+ }
649
+ // The bossac runner defaults its port to /dev/ttyACM0 — on Windows that
650
+ // never matches, so the port MUST be forwarded or bossac fails with
651
+ // "No device found on /dev/ttyACM0" (same class of port-forwarding
652
+ // problem as the esptool --esp-device above).
653
+ if (port && flashRunner === 'bossac') {
654
+ args.push('--bossac-port', port);
655
+ }
656
+ // Extra runner-specific flags, appended verbatim (west's runner parsers
657
+ // accept them after the runner is selected).
658
+ if (runnerArgs && runnerArgs.length > 0) {
659
+ args.push(...runnerArgs);
660
+ }
661
+ return args;
662
+ }
663
+
664
+ /**
665
+ * Classify a `west flash` result as success/failure.
666
+ *
667
+ * west's exit status is authoritative except for one known race in the uf2
668
+ * runner on Windows: the UF2 bootloader reboots to run new firmware the instant
669
+ * the file copy completes, unmounting the USB-MSC drive before `shutil.copy`'s
670
+ * trailing `copymode`/chmod runs. That raises `OSError: [WinError 433] A
671
+ * device which does not exist was specified` and makes west exit non-zero —
672
+ * even though the firmware copied and flashed correctly (the LED blinks).
673
+ *
674
+ * The copy starting is logged ("Copying UF2 file to '<drive>'"); WinError 433
675
+ * during `copymode` after that point proves the data write finished and the
676
+ * drive only vanished on the metadata step. Treat that exact signature as
677
+ * success so the upload isn't reported as a failure. Genuine uf2 failures
678
+ * (no partition found, write errors before the copy) still surface as failures.
679
+ *
680
+ * Exported (pure) so the classification is unit-testable without spawning west.
681
+ */
682
+ export function classifyUploadResult(
683
+ runner: string | undefined,
684
+ status: number | null,
685
+ output: string,
686
+ ): boolean {
687
+ if (status === 0) return true;
688
+ if (isUf2DriveVanishRace(output)) return runner === 'uf2';
689
+ return false;
690
+ }
691
+
692
+ /**
693
+ * Whether `output` carries the benign UF2 copymode/WinError-433 race signature
694
+ * (see classifyUploadResult). Centralized so classify + cleanse share one match.
695
+ */
696
+ function isUf2DriveVanishRace(output: string): boolean {
697
+ return /Copying UF2 file to/.test(output)
698
+ && /WinError 433/.test(output)
699
+ && /copymode/.test(output);
700
+ }
701
+
702
+ /**
703
+ * Whether a `west flash` (openocd) output carries one of the known
704
+ * target-ignored-SWD signatures — the DAP connect failing ("init mode
705
+ * failed (unable to connect to the target)", i.e. the DPIDR read never
706
+ * succeeded) or a reset/halt never landing ("timed out while waiting for
707
+ * target halted" / "TARGET: <name> - Not halted"). Both mean the board (or
708
+ * probe) needs a power-cycle or the SWD-free DFU path, not a retry of the
709
+ * same command. Centralized so the upload hint stays testable.
710
+ */
711
+ export function isTargetSwdFailure(output: string): boolean {
712
+ return /unable to connect to the target|timed out while waiting for target halted|TARGET: \S+ - Not halted/.test(output);
713
+ }
714
+
715
+ /**
716
+ * Cleanse the `west flash` output shown to the user.
717
+ *
718
+ * When classifyUploadResult has decided a non-zero west exit was the benign UF2
719
+ * race (firmware copied, drive unmounted on the trailing chmod), the raw output
720
+ * is a wall of Python traceback that reads like a hard failure. Drop everything
721
+ * after the "Copying UF2 file to" line — i.e. the entire traceback — so a
722
+ * successful flash reads as a success (the framework's ✓ Done follows). Non-race
723
+ * output is returned untouched; genuine errors stay fully visible for diagnosis.
724
+ *
725
+ * Exported (pure) so the cleansing is unit-testable without spawning west.
726
+ */
727
+ export function cleanseUploadOutput(
728
+ runner: string | undefined,
729
+ status: number | null,
730
+ output: string,
731
+ ): string {
732
+ if (status === 0) return output;
733
+ if (runner === 'uf2' && isUf2DriveVanishRace(output)) {
734
+ // Keep everything west printed up to and including "Copying UF2 file to",
735
+ // then stop — everything after that is the drive-vanish traceback.
736
+ const upto = output.match(/[\s\S]*Copying UF2 file to[^\n]*/);
737
+ const head = upto ? upto[0] : '-- west flash: using runner uf2';
738
+ return head;
739
+ }
740
+ return output;
741
+ }
742
+
743
+
744
+ /**
745
+ * Whether a failed `west build` output carries ninja's `dependency cycle`
746
+ * signature. Zephyr 4.3.99-dev snapshots have a regression
747
+ * (zephyrproject-rtos/zephyr#104757, fixed upstream by the #104784 revert,
748
+ * in v4.4+): after CMake re-runs from a .config change, the build dir's
749
+ * .ninja_deps records an `offsets.h -> offsets.c.obj -> offsets.h` cycle and
750
+ * ninja aborts with `ninja: error: dependency cycle: ...` before compiling
751
+ * anything. The cycle lives in the build dir, not the sources, so compile()
752
+ * recovers by deleting the dir and retrying once.
753
+ *
754
+ * Exported (pure) so the detection is unit-testable without spawning west.
755
+ */
756
+ export function isDependencyCycleFailure(output: string): boolean {
757
+ return output.includes('dependency cycle');
758
+ }
759
+
760
+ /** stdout+stderr of a spawnSync result coerced to one string. Defensive about
761
+ * the buffer form (spawnSync only returns strings when `encoding` is set,
762
+ * which every call site here does — but the coercion costs nothing). */
763
+ function combinedSpawnOutput(
764
+ result: { stdout?: string | Buffer | null; stderr?: string | Buffer | null },
765
+ ): string {
766
+ const so = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
767
+ const se = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
768
+ return so + se;
769
+ }
770
+
771
+ /**
772
+ * FrameworkToolchain for Zephyr. Spec §3.5 (mirror of the ESP-IDF toolchain).
773
+ * The target board is carried via frameworkData.buildTarget; scaffolding
774
+ * happens at compile time when the target is known.
775
+ */
776
+ export const Toolchain = {
777
+ prepare(outputDir: string, entryPoint: string): void {
778
+ // Write the DT overlay for the default board (the real target is known at
779
+ // compile time; prepare runs before compile, so use the default board id).
780
+ // The overlay is additive and idempotent; compile re-runs prepare-equivalent
781
+ // logic in scaffold via the usage scan. Mirrors how Arduino's library
782
+ // resolution is a pre-build artifact step.
783
+ const projectRoot = basename(outputDir) === 'src' ? dirname(outputDir) : outputDir;
784
+ // Scan the emitted source for usage tokens (same authoritative signal the
785
+ // scaffold uses). entryPoint is the path to main.cpp; its dir is src/.
786
+ const srcDir = dirname(entryPoint);
787
+ let src = '';
788
+ try {
789
+ for (const name of readdirSync(srcDir)) {
790
+ if (name.endsWith('.cpp') || name.endsWith('.c')) {
791
+ src += readFileSync(join(srcDir, name), 'utf8');
792
+ }
793
+ }
794
+ } catch { /* src may not exist yet on first prepare */ }
795
+ const uses = (t: string): boolean => src.includes(t);
796
+ // Inline-override markers (the escape hatch): the lowerings emit
797
+ // `cuttlefish-user-facts` comments carrying routing the transpiler
798
+ // cannot synthesize (adc device/pinctrl, pwm controller/channel).
799
+ // Merged into the chip BEFORE the scans + overlay generation, so the DT
800
+ // nodes, pinctrl groups, and used-pin recovery treat them as facts.
801
+ const chip = applyUserFactMarkers(chipForBuild(projectRoot, ''), src);
802
+ // Display usage tokens: the minimal GFX runtime (display_write/_fill_rect)
803
+ // and the UI display adapter (display_init / __tc_display_dev /
804
+ // DEVICE_DT_GET on the display nodelabel). Both paths need the DT overlay
805
+ // to enable the display node.
806
+ const usesDisplay = uses('display_write') || uses('display_init')
807
+ || uses('display_fill_rect') || uses('__tc_display_dev')
808
+ || uses('CuttlefishDisplayTarget');
809
+ // Recover the exact display profile from the marker the adapter stamped
810
+ // into the emitted source (the registry is the single source of truth).
811
+ // Fall back to the default profile only when a display is used but no
812
+ // marker matched (e.g. hand-written source).
813
+ const displayProfile = usesDisplay
814
+ ? (profileFromEmittedSource(src) ?? DEFAULT_ZEPHYR_DISPLAY_PROFILE)
815
+ : undefined;
816
+ // Touch controller kind comes from which DT nodelabel the emitted adapter
817
+ // references (FT6336U on I2C, XPT2046 on the display's SPI bus).
818
+ const usesTouch = uses('ft6336u') || uses('touch_');
819
+ const usesXpt = uses('xpt2046');
820
+ const sensorParts = scanSensorParts(src);
821
+ const spiTargetParts = scanSpiTargets(src);
822
+ const overlay = generateOverlay(chip, {
823
+ // __tc_<bus> matches the shim state block — a begin()-only program
824
+ // emits no driver API call but still declares the DT device. A
825
+ // constructed sensor is also a bus user (its device handle is the
826
+ // only i2c reference a sensor-only program carries).
827
+ usesI2c: uses('i2c_') || uses('__tc_i2c') || sensorParts.length > 0,
828
+ usesSensor: uses('sensor_') || sensorParts.length > 0,
829
+ usesFloatFormat: /%[-0-9.]*[eEfFgG]/.test(src),
830
+ sensorParts,
831
+ spiTargets: spiTargetParts,
832
+ usesSpi: uses('spi_') || uses('__tc_spi') || spiTargetParts.length > 0,
833
+ usesUart: uses('__tc_uart'),
834
+ usesUsb: uses('__tc_usb0'),
835
+ usesPwm: uses('pwm_'),
836
+ usesAdc: uses('adc_'),
837
+ adcReadPins: scanAdcReadPins(src, chip),
838
+ dacWritePins: scanDacWritePins(src),
839
+ pwmUsedPins: scanPwmUsedPins(src, chip),
840
+ usesStrip: uses('led_strip'),
841
+ strips: scanStrips(src),
842
+ usesClock: uses('__tc_rtc'),
843
+ usesCan: uses('can_'),
844
+ canLoopback: uses('CAN_MODE_LOOPBACK'),
845
+ usesI2s: uses('i2s_'),
846
+ clockShimCounter: chip.hwtimer && chip.hwtimer.controllers.length > 0
847
+ ? {
848
+ label: chip.hwtimer.controllers[0].nodeLabel,
849
+ ...(chip.hwtimer.controllers[0].counterParent ? { parent: chip.hwtimer.controllers[0].counterParent } : {}),
850
+ }
851
+ : undefined,
852
+ usesHid: uses('__tc_hid_'),
853
+ hidProtocol: scanHid(src),
854
+ usesMatrix: uses('__tc_matrix'),
855
+ matrix: scanMatrix(src),
856
+ usesPower: uses('sys_poweroff'),
857
+ i2cUsedInstances: scanUsedBusInstances(src, chip.i2c?.controllers, 'i2c'),
858
+ spiUsedInstances: scanUsedBusInstances(src, chip.spi?.controllers, 'spi'),
859
+ uartUsedInstances: scanUsedBusInstances(src, chip.uart?.controllers, 'uart'),
860
+ // Preferences/FS — same tokens scaffoldZephyrProject scans (the ZMS
861
+ // settings_* API + __tc_prefs shim, the __tc_fs mount shim); drive the
862
+ // storage-partition synthesis + /chosen settings pointer.
863
+ usesPreferences: uses('settings_') || uses('__tc_prefs'),
864
+ usesFS: uses('__tc_fs'),
865
+ usesWdt: uses('wdt_'),
866
+ usesHwtimer: uses('counter_') || uses('__tc_hw'),
867
+ usesDisplay,
868
+ usesTouch: usesTouch || usesXpt,
869
+ touchController: usesXpt ? 'xpt2046' : 'ft6336u',
870
+ }, displayProfile);
871
+ const overlayDir = join(projectRoot, 'boards');
872
+ mkdirSync(overlayDir, { recursive: true });
873
+ // prepare() runs before the real target is known — the placeholder name
874
+ // never matches `west build -b <board>` (compile rewrites the overlay
875
+ // under the actual board's name below).
876
+ writeIfChanged(join(overlayDir, 'board.overlay'), overlay);
877
+ // Thin SPI targets need an app-local binding: a compatible-less DT node
878
+ // generates NO property macros, so SPI_DT_SPEC_GET's spi-max-frequency
879
+ // lookup would not exist. The binding has no driver — it exists so
880
+ // gen_defines emits the spi properties for the target nodes.
881
+ if (spiTargetParts.length > 0) {
882
+ const bindingsDir = join(projectRoot, 'dts', 'bindings');
883
+ mkdirSync(bindingsDir, { recursive: true });
884
+ writeIfChanged(join(bindingsDir, 'cuttlefish,spi-target.yaml'), [
885
+ 'description: |',
886
+ ' Cuttlefish thin SPITarget (hal/spi-target.ts) — a raw spi_dt_spec',
887
+ ' peer. No driver binds this compatible; it exists so devicetree',
888
+ ' generation emits the spi properties (spi-max-frequency,',
889
+ ' spi-cpol/spi-cpha, reg = the cs-gpios index) that SPI_DT_SPEC_GET',
890
+ ' consumes from the generated C++.',
891
+ 'compatible: "cuttlefish,spi-target"',
892
+ 'include: spi-device.yaml',
893
+ '',
894
+ ].join('\n'));
895
+ }
896
+ },
897
+
898
+ compile(o: ToolchainOptions): CompileResult {
899
+ const projectRoot = projectRootFromOptions(o);
900
+ const rawBoard = targetFromOptions(o);
901
+
902
+ // Fail fast on an incompatible Zephyr (clear message vs. a cryptic west/
903
+ // CMake board error), then normalize the board target for the installed
904
+ // version — Zephyr 4.3+ rejects bare multi-core board names, so a stale
905
+ // config (esp32s3_devkitc) is rewritten to the qualified form
906
+ // (esp32s3_devkitc/esp32s3/procpu). See toolchain/compat.ts.
907
+ const zephyrVersion = detectZephyrVersion();
908
+ const compat = checkZephyrCompat(zephyrVersion);
909
+ if (compat.status === 'out-of-range') {
910
+ throw new Error(
911
+ `Zephyr ${zephyrVersion} is outside the supported range (${compat.range}) for @typecad/framework-zephyr. ` +
912
+ `Set ZEPHYR_BASE to a compatible Zephyr checkout, or install one via '@typecad/zephyr-installer'.`,
913
+ );
914
+ }
915
+ if (compat.status === 'undetectable') {
916
+ console.warn(
917
+ `! Could not detect the installed Zephyr version (is ZEPHYR_BASE set?). ` +
918
+ `Skipping compat check; declared range is ${compat.range}.`,
919
+ );
920
+ }
921
+ const board = resolveBoardTarget(rawBoard, zephyrVersion);
922
+
923
+ const debugMode = new ZephyrStrategy().debugMode(board);
924
+ const isGdbDebug = o.debug === true && debugMode === 'gdb';
925
+ const zc = o.zephyrConfig as Record<string, unknown> | undefined;
926
+ const userKconfig = zc?.kconfig as Record<string, string> | undefined;
927
+ const traceCfg = zc?.trace as { enabled?: boolean; intervalMs?: number } | undefined;
928
+ const configChanged = scaffoldZephyrProject(projectRoot, isGdbDebug, userKconfig, o.psram, traceCfg);
929
+
930
+ // Regenerate the DT overlay for the ACTUAL target board. prepare() writes
931
+ // it for the default board (the real target is unknown until compile), so
932
+ // the <default>.overlay it wrote does not match `west build -b <board>`.
933
+ // Zephyr auto-detects boards/<board>.overlay under APPLICATION_CONFIG_DIR.
934
+ try {
935
+ const chip = chipForBuild(projectRoot, board);
936
+ // Custom-board generation: an MCU-only target (no board package) has no
937
+ // upstream Zephyr board — generate one under boards/typecad/<name>/ from
938
+ // the chip's silicon data. Opt-in via `zephyr.customBoard: true` in
939
+ // typecad-hal.config.ts; the board takes its name from the build target.
940
+ // Idempotent — regenerated on every compile, before the overlay pass.
941
+ if (zc?.customBoard === true) {
942
+ const generated = generateCustomBoard(projectRoot, chip, board.split('/')[0]);
943
+ if (!generated) {
944
+ throw new Error(
945
+ `zephyr.customBoard is set, but the resolved chip ('${chip.id}') carries no ` +
946
+ `silicon board data. Custom-board generation requires an MCU-only config ` +
947
+ `(mcu set, board absent) whose MCU package ships a zephyr block.`,
948
+ );
949
+ }
950
+ }
951
+ const srcDir = join(projectRoot, 'src');
952
+ let src = '';
953
+ try {
954
+ for (const name of readdirSync(srcDir)) {
955
+ if (name.endsWith('.cpp') || name.endsWith('.c')) {
956
+ src += readFileSync(join(srcDir, name), 'utf-8');
957
+ }
958
+ }
959
+ } catch { /* src may not exist */ }
960
+ const uses = (t: string): boolean => src.includes(t);
961
+ const usesDisplay = uses('display_write') || uses('display_init')
962
+ || uses('display_fill_rect') || uses('__tc_display_dev')
963
+ || uses('CuttlefishDisplayTarget');
964
+ // Recover the exact display profile from the marker the adapter emitted
965
+ // (`typecad-display-profile: <driver>`), so the DT overlay's compatible
966
+ // string, geometry, and rotation match the panel the adapter targets.
967
+ // The width/height regex this replaces could produce franken-profiles —
968
+ // e.g. an ST7796S build (480x320) on the default ILI9341 profile's
969
+ // controller/compatible, which emitted an ilitek,ili9341 DT node for a
970
+ // panel the ST7796S adapter drives.
971
+ const displayProfile = usesDisplay
972
+ ? (profileFromEmittedSource(src) ?? DEFAULT_ZEPHYR_DISPLAY_PROFILE)
973
+ : undefined;
974
+ // Extract display pin wiring (cs/dc/rst/spiFrequency/spiPins) from the
975
+ // config display section so the DT overlay wires the MIPI DBI bridge to
976
+ // the correct GPIOs + SPI bus pins.
977
+ const dispCfg = o.display as Record<string, unknown> | undefined;
978
+ const spiPins = (dispCfg?.spiPins ?? undefined) as
979
+ { sck?: unknown; mosi?: unknown; miso?: unknown } | undefined;
980
+ const i2cPins = (dispCfg?.i2cPins ?? undefined) as
981
+ { sda?: unknown; scl?: unknown } | undefined;
982
+ const wiring: DisplayWiring | undefined = dispCfg
983
+ ? {
984
+ cs: typeof dispCfg.cs === 'number' ? dispCfg.cs : undefined,
985
+ dc: typeof dispCfg.dc === 'number' ? dispCfg.dc : undefined,
986
+ rst: typeof dispCfg.rst === 'number' ? dispCfg.rst : undefined,
987
+ spiFrequency: typeof dispCfg.spiFrequency === 'number' ? dispCfg.spiFrequency : undefined,
988
+ sck: typeof spiPins?.sck === 'number' ? spiPins.sck : undefined,
989
+ mosi: typeof spiPins?.mosi === 'number' ? spiPins.mosi : undefined,
990
+ miso: typeof spiPins?.miso === 'number' ? spiPins.miso : undefined,
991
+ sda: typeof i2cPins?.sda === 'number' ? i2cPins.sda : undefined,
992
+ scl: typeof i2cPins?.scl === 'number' ? i2cPins.scl : undefined,
993
+ backlightPin: typeof dispCfg.backlightPin === 'number' ? dispCfg.backlightPin : undefined,
994
+ tearingEffectPin: typeof dispCfg.tearingEffectPin === 'number' ? dispCfg.tearingEffectPin : undefined,
995
+ busyPin: typeof (dispCfg as { busyPin?: number }).busyPin === 'number' ? (dispCfg as { busyPin?: number }).busyPin : undefined,
996
+ address: typeof dispCfg.address === 'number' ? dispCfg.address : undefined,
997
+ }
998
+ : undefined;
999
+ // Extract touch pin wiring from the config display.touch section so the
1000
+ // DT overlay wires the bus + touch node. I2C (FT6336U) carries
1001
+ // irq/resetPin/sda/scl; SPI (XPT2046) carries irq/cs + the calibration
1002
+ // range the xptek,xpt2046 binding requires.
1003
+ const touchCfg = dispCfg?.touch as Record<string, unknown> | undefined;
1004
+ const isXpt = touchCfg?.library === 'XPT2046_Touchscreen';
1005
+ const touchCal = touchCfg?.calibration as
1006
+ { xMin?: unknown; xMax?: unknown; yMin?: unknown; yMax?: unknown } | undefined;
1007
+ const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined);
1008
+ let touchWiring: TouchWiring | undefined = touchCfg
1009
+ ? {
1010
+ controller: isXpt ? 'xpt2046' : 'ft6336u',
1011
+ irq: num(touchCfg.irq),
1012
+ resetPin: num(touchCfg.resetPin),
1013
+ sda: num(touchCfg.sda),
1014
+ scl: num(touchCfg.scl),
1015
+ cs: num(touchCfg.cs),
1016
+ calibration: touchCal
1017
+ ? {
1018
+ xMin: num(touchCal.xMin) ?? 0,
1019
+ xMax: num(touchCal.xMax) ?? 4095,
1020
+ yMin: num(touchCal.yMin) ?? 0,
1021
+ yMax: num(touchCal.yMax) ?? 4095,
1022
+ }
1023
+ : undefined,
1024
+ minPressure: num(touchCfg.minPressure),
1025
+ }
1026
+ : undefined;
1027
+ // Touch controller kind for Kconfig (bus driver selection) and the DT
1028
+ // node shape: from the config when available, else from the DT nodelabel
1029
+ // the emitted adapter references. Forced onto touchWiring so a source
1030
+ // scan match without a config section still emits the right node.
1031
+ const usesXpt = isXpt || uses('xpt2046');
1032
+ if (usesXpt) {
1033
+ touchWiring = { controller: 'xpt2046', ...(touchWiring ?? {}) };
1034
+ }
1035
+ const overlayDiagnostics: OverlayDiagnostic[] = [];
1036
+ const sensorParts = scanSensorParts(src);
1037
+ const spiTargetParts = scanSpiTargets(src);
1038
+ const overlay = generateOverlay(chip, {
1039
+ // __tc_<bus> matches the shim state block — a begin()-only program
1040
+ // emits no driver API call but still declares the DT device. A
1041
+ // constructed sensor is also a bus user (see scanSensorParts).
1042
+ usesI2c: uses('i2c_') || uses('__tc_i2c') || sensorParts.length > 0,
1043
+ usesSensor: uses('sensor_') || sensorParts.length > 0,
1044
+ usesFloatFormat: /%[-0-9.]*[eEfFgG]/.test(src),
1045
+ sensorParts,
1046
+ spiTargets: spiTargetParts,
1047
+ usesSpi: uses('spi_') || uses('__tc_spi') || spiTargetParts.length > 0,
1048
+ usesUart: uses('__tc_uart'),
1049
+ usesUsb: uses('__tc_usb0'),
1050
+ usesPwm: uses('pwm_'),
1051
+ usesAdc: uses('adc_'),
1052
+ adcReadPins: scanAdcReadPins(src, chip),
1053
+ usesDac: uses('dac_') || uses('__tc_dac'),
1054
+ dacWritePins: scanDacWritePins(src),
1055
+ pwmUsedPins: scanPwmUsedPins(src, chip),
1056
+ usesStrip: uses('led_strip'),
1057
+ strips: scanStrips(src),
1058
+ usesHid: uses('__tc_hid_'),
1059
+ hidProtocol: scanHid(src),
1060
+ usesMatrix: uses('__tc_matrix'),
1061
+ matrix: scanMatrix(src),
1062
+ usesPower: uses('sys_poweroff'),
1063
+ usesClock: uses('__tc_rtc'),
1064
+ usesCan: uses('can_'),
1065
+ canLoopback: uses('CAN_MODE_LOOPBACK'),
1066
+ usesI2s: uses('i2s_'),
1067
+ clockShimCounter: chip.hwtimer && chip.hwtimer.controllers.length > 0
1068
+ ? {
1069
+ label: chip.hwtimer.controllers[0].nodeLabel,
1070
+ ...(chip.hwtimer.controllers[0].counterParent ? { parent: chip.hwtimer.controllers[0].counterParent } : {}),
1071
+ }
1072
+ : undefined,
1073
+ i2cUsedInstances: scanUsedBusInstances(src, chip.i2c?.controllers, 'i2c'),
1074
+ spiUsedInstances: scanUsedBusInstances(src, chip.spi?.controllers, 'spi'),
1075
+ uartUsedInstances: scanUsedBusInstances(src, chip.uart?.controllers, 'uart'),
1076
+ // Preferences/FS — same tokens scaffoldZephyrProject scans; drive the
1077
+ // storage-partition synthesis + /chosen settings pointer.
1078
+ usesPreferences: uses('settings_') || uses('__tc_prefs'),
1079
+ usesWdt: uses('wdt_'),
1080
+ usesHwtimer: uses('counter_') || uses('__tc_hw'),
1081
+ usesFS: uses('__tc_fs'),
1082
+ usesDisplay,
1083
+ usesTouch: uses('ft6336u') || uses('touch_') || usesXpt,
1084
+ touchController: usesXpt ? 'xpt2046' : 'ft6336u',
1085
+ psram: o.psram,
1086
+ }, displayProfile, wiring, touchWiring, overlayDiagnostics);
1087
+ for (const d of overlayDiagnostics) {
1088
+ console.warn(`overlay: ${d.message}`);
1089
+ }
1090
+ const overlayDir = join(projectRoot, 'boards');
1091
+ mkdirSync(overlayDir, { recursive: true });
1092
+ // Write the board-specific overlay (the one west loads). Zephyr looks for
1093
+ // boards/<board_id>.overlay under APPLICATION_CONFIG_DIR — use the bare
1094
+ // board id (before any hardware-qualifier suffix, e.g. 'esp32_devkitc'
1095
+ // not the full 'esp32_devkitc/esp32/procpu' target string). Library
1096
+ // packages' overlay fragments are appended by the scaffold helper.
1097
+ const boardId = board.split('/')[0];
1098
+ writeIfChanged(
1099
+ join(overlayDir, `${boardId}.overlay`),
1100
+ appendLibraryOverlayFragments(overlay, projectRoot),
1101
+ );
1102
+ // Thin SPI targets need the app-local binding (see the transpile-side
1103
+ // write for the rationale): no compatible → no generated spi props →
1104
+ // SPI_DT_SPEC_GET's spi-max-frequency lookup does not exist.
1105
+ if (spiTargetParts.length > 0) {
1106
+ const bindingsDir = join(projectRoot, 'dts', 'bindings');
1107
+ mkdirSync(bindingsDir, { recursive: true });
1108
+ writeIfChanged(join(bindingsDir, 'cuttlefish,spi-target.yaml'), [
1109
+ 'description: |',
1110
+ ' Cuttlefish thin SPITarget (hal/spi-target.ts) — a raw spi_dt_spec',
1111
+ ' peer. No driver binds this compatible; it exists so devicetree',
1112
+ ' generation emits the spi properties (spi-max-frequency,',
1113
+ ' spi-cpol/spi-cpha, reg = the cs-gpios index) that SPI_DT_SPEC_GET',
1114
+ ' consumes from the generated C++.',
1115
+ 'compatible: "cuttlefish,spi-target"',
1116
+ 'include: spi-device.yaml',
1117
+ '',
1118
+ ].join('\n'));
1119
+ }
1120
+ } catch { /* best-effort overlay regen; the build surfaces DT errors */ }
1121
+
1122
+ // Use a stable build dir so incremental builds reuse the Ninja graph.
1123
+ // west defaults to <projectRoot>/build.
1124
+ const buildDir = join(projectRoot, 'build');
1125
+
1126
+ // Reuse the build dir across builds so ninja recompiles only the changed
1127
+ // app translation units and re-links — a pristine configure + the
1128
+ // ~280-target Zephyr library rebuild costs minutes on Windows
1129
+ // (demo-shadcn measures 69s of ninja wall time, 448s of summed compile
1130
+ // work, and every build redid all of it). Nuke it only when the generated
1131
+ // config changed (prj.conf / CMakeLists content), the one path that must
1132
+ // not reuse a cached graph: Zephyr 4.3.99-dev snapshots carry a
1133
+ // regression (zephyrproject-rtos/zephyr#104757, fixed by the #104784
1134
+ // revert on 2026-03-03, in v4.4+) where re-running CMake after a .config
1135
+ // change records an `offsets.h -> offsets.c.obj -> offsets.h` cycle in
1136
+ // .ninja_deps, after which every ninja run fails with `dependency cycle`.
1137
+ // Plain source edits never reconfigure CMake, so they cannot trigger it —
1138
+ // and the retry after the spawn below self-heals any path that still does.
1139
+ // Board switches DO need a nuke: `west build`'s --pristine=auto covers
1140
+ // cmake/config churn, not a -b <board> mismatch — west aborts with
1141
+ // "refusing to proceed without --force" and cuttlefish doesn't forward
1142
+ // that flag, so the user would be stuck deleting the dir by hand. The
1143
+ // cache names the board it was configured for (BOARD:STRING); detect the
1144
+ // mismatch and apply west's own suggested remedy automatically.
1145
+ const cachedBoard = cachedBuildBoard(buildDir);
1146
+ const boardChanged = Boolean(cachedBoard && cachedBoard !== board);
1147
+ if (configChanged || boardChanged) {
1148
+ try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
1149
+ }
1150
+
1151
+ const buildArgs = ['build', '-b', board, '-d', buildDir, projectRoot];
1152
+ // Explicitly pass the generated DT overlay. Zephyr's auto-detection of
1153
+ // boards/<board>.overlay fails for hardware-qualified targets (e.g.
1154
+ // esp32_devkitc/esp32/procpu) because the FILE_SUFFIX matching doesn't
1155
+ // resolve — passing -DDTC_OVERLAY_FILE forces it unconditionally.
1156
+ const boardId = board.split('/')[0];
1157
+ const overlayPath = join(projectRoot, 'boards', `${boardId}.overlay`);
1158
+ try {
1159
+ if (readFileSync(overlayPath, 'utf-8').length > 0) {
1160
+ // CMake parses backslashes as escapes — use forward slashes so the
1161
+ // Windows path survives the -D argument intact.
1162
+ buildArgs.push('--', `-DDTC_OVERLAY_FILE=${overlayPath.replace(/\\/g, '/')}`);
1163
+ }
1164
+ } catch { /* no overlay — let Zephyr auto-detect or build without one */ }
1165
+ // Append user cmake args from typecad-hal.config.ts zephyr.cmakeArgs.
1166
+ const userCmakeArgs = zc?.cmakeArgs as string[] | undefined;
1167
+ if (userCmakeArgs && userCmakeArgs.length > 0) {
1168
+ if (!buildArgs.includes('--')) buildArgs.push('--');
1169
+ buildArgs.push(...userCmakeArgs);
1170
+ }
1171
+ const inv = westSpawn(
1172
+ buildArgs,
1173
+ { cwd: projectRoot, encoding: 'utf-8', timeout: BUILD_TIMEOUT_MS },
1174
+ );
1175
+ let result = spawnSync(inv.command, inv.args, inv.options);
1176
+ // Self-heal the Zephyr 4.3.99 dep-cycle regression (see the nuke comment
1177
+ // above): when the cached .ninja_deps carries the cycle, ninja aborts with
1178
+ // `dependency cycle` before compiling anything. The cycle lives in the
1179
+ // build dir, not the sources — one pristine retry clears it and the build
1180
+ // proceeds. On fixed Zephyr (>=4.4) this never fires.
1181
+ let pristineRetry = false;
1182
+ if (result.status !== 0 && isDependencyCycleFailure(combinedSpawnOutput(result))) {
1183
+ try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* may not exist */ }
1184
+ result = spawnSync(inv.command, inv.args, inv.options);
1185
+ pristineRetry = true;
1186
+ }
1187
+
1188
+ const stdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
1189
+ const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
1190
+ const output = stdout + stderr + (pristineRetry
1191
+ ? '\n[typecad-hal] dependency cycle detected in the cached build dir — retried with a pristine build'
1192
+ : '');
1193
+ // Prefix the build log with how west was resolved, for transparency.
1194
+ const header = `Using west via ${inv.install.source}` +
1195
+ (inv.install.zephyrBase ? ` (ZEPHYR_BASE=${inv.install.zephyrBase})` : '') + '\n';
1196
+
1197
+ // After a successful build on a gdb-capable board, keep the VS Code debug
1198
+ // artifacts current: always under --debug, or on a plain build when they
1199
+ // need it (still in create-time starter shape, an outDir rename moved the
1200
+ // app root, or a pre-west self-managed-server entry). Non-fatal on
1201
+ // failure — a missing artifact doesn't block the build.
1202
+ if (result.status === 0 && debugMode === 'gdb') {
1203
+ try {
1204
+ const { workspaceRoot, appRel } = resolveDebugLocations(projectRoot);
1205
+ if (isGdbDebug
1206
+ || debugArtifactsNeedRewrite(workspaceRoot, appRel, readRunnersFacts(buildDir)?.gdb?.replace(/\\/g, '/'))) {
1207
+ writeDebugConfig({
1208
+ projectRoot,
1209
+ workspaceRoot,
1210
+ appRel,
1211
+ target: board,
1212
+ buildDir,
1213
+ sourceMapPath: join(dirname(o.sourcePath), `${basename(o.sourcePath)}.thcppmap.json`),
1214
+ });
1215
+ }
1216
+ } catch (e) {
1217
+ console.warn(`[typecad-hal] gdb debug config generation failed: ${(e as Error).message}`);
1218
+ }
1219
+ }
1220
+
1221
+ // As-built snapshot: after a successful build, the resolved devicetree
1222
+ // at <buildDir>/zephyr/zephyr.dts carries the board's pinctrl labels —
1223
+ // the STABLE name grammar, immune to vendor macro churn. Harvest its
1224
+ // routes into .typecad-hal/as-built.json; the next build's board-module
1225
+ // generation merges them per-pin over the catalog harvest (build wins,
1226
+ // silently when they agree). One-build freshness lag on first setup,
1227
+ // self-maintaining after. Best-effort — a missing/unparseable artifact
1228
+ // never fails the build.
1229
+ if (result.status === 0) {
1230
+ try {
1231
+ const dtsPath = join(buildDir, 'zephyr', 'zephyr.dts');
1232
+ const dtsText = readFileSync(dtsPath, 'utf8');
1233
+ const facts = parseZephyrDts(dtsText);
1234
+ const total = facts.adc.length + facts.pwm.length + facts.dac.length;
1235
+ if (total > 0) {
1236
+ // Write beside the project's board module — the .typecad-hal dir the
1237
+ // config loader reads from, discovered by walking up to the
1238
+ // generated board.json (the scaffold root and the config root are
1239
+ // different dirs in the standard layout: src/out vs project root).
1240
+ let cfDir = join(projectRoot, '.typecad-hal');
1241
+ for (let dir = projectRoot; ; dir = dirname(dir)) {
1242
+ if (existsSync(join(dir, '.typecad-hal', 'board.json'))) {
1243
+ cfDir = join(dir, '.typecad-hal');
1244
+ break;
1245
+ }
1246
+ const parent = dirname(dir);
1247
+ if (parent === dir) break;
1248
+ }
1249
+ mkdirSync(cfDir, { recursive: true });
1250
+ writeIfChanged(join(cfDir, 'as-built.json'), asBuiltJson(board, facts));
1251
+ }
1252
+ } catch { /* best-effort snapshot — nothing to harvest or unreadable */ }
1253
+ }
1254
+
1255
+ // SBOM stamp: after a successful build, record what the binary is made of
1256
+ // — kernel + linked west modules with commit SHAs + the hashed firmware
1257
+ // artifact (CycloneDX 1.6, next to zephyr.bin) — so `typecad-hal sbom
1258
+ // --check` can gate releases on the record still matching the build
1259
+ // (EU CRA essential-requirements support). Best-effort: a west-list or
1260
+ // hashing failure never blocks the build.
1261
+ if (result.status === 0) {
1262
+ try {
1263
+ stampBuildSbom({ buildDir, board, projectRoot });
1264
+ } catch { /* best-effort SBOM stamp */ }
1265
+ }
1266
+
1267
+ return {
1268
+ success: result.status === 0,
1269
+ output: header + output,
1270
+ errors: parseCompileErrors(output, o.sourcePath),
1271
+ };
1272
+ },
1273
+
1274
+ upload(o: ToolchainOptions): UploadResult {
1275
+ const projectRoot = projectRootFromOptions(o);
1276
+ const buildDir = join(projectRoot, 'build');
1277
+ const board = targetFromOptions(o);
1278
+ const zc = o.zephyrConfig as Record<string, unknown> | undefined;
1279
+ const chip = chipForBuild(projectRoot, board);
1280
+ const probe = resolveProbeMethod(zc, chip, 'flash');
1281
+ if (!probe.ok) {
1282
+ return { success: false, output: `-- west flash: ${probe.error}` };
1283
+ }
1284
+ // Flashing over a probe needs it EXCLUSIVE: a debug server still bound to
1285
+ // the gdb port (live session or an orphan whose wrapper died — a VS Code
1286
+ // window reload kills task terminals without killing their children on
1287
+ // Windows) makes openocd fail with LIBUSB_ERROR_ACCESS before any retry
1288
+ // logic can help. Reclaim it up front — it is ours by convention.
1289
+ {
1290
+ const holder = portOwnerPid(DEBUG_SERVER_PORT);
1291
+ if (holder !== undefined && holder !== process.pid) {
1292
+ console.log(`-- west flash: stopping debug server (pid ${holder}) — flashing needs exclusive probe access`);
1293
+ killPidTree(holder);
1294
+ try {
1295
+ rmSync(join(projectRoot, '.typecad-hal', 'debug-server.pid'), { force: true });
1296
+ } catch { /* already gone */ }
1297
+ }
1298
+ }
1299
+ // BOSSA bootloader boards with touch-reset data: open the app's console
1300
+ // port at 1200 baud (the firmware's USB shim reboots into the
1301
+ // bootloader), wait for the bootloader identity, and flash THAT port.
1302
+ // Falls back to the configured port (manual double-tap) on any failure.
1303
+ let flashPort = o.port;
1304
+ const flashNotes: string[] = [];
1305
+ // The runner this flash will actually use: the explicit choice, else the
1306
+ // board's declared default (first probe-method entry). Gates port
1307
+ // forwarding and the bossac touch below — board.cmake still resolves
1308
+ // the default runner itself.
1309
+ const flashRunner = probe.runner ?? chip.probeMethods?.[0]?.runner;
1310
+ // Serial-port runners cannot flash without a port; every other runner
1311
+ // (probe or USB) proceeds — whether a port is required is the runner's
1312
+ // call, not the CLI's blanket gate.
1313
+ if (uploadRequiresPort(flashRunner) && !flashPort) {
1314
+ return {
1315
+ success: false,
1316
+ output: `-- upload requires a port for ${flashRunner} flashing. Set --port <port> on the command line (or the TYPECAD_HAL_PORT env var).`,
1317
+ };
1318
+ }
1319
+ if (flashRunner === 'bossac' && flashPort && chip.usb?.touchReset) {
1320
+ const touch = bossacTouchReset(flashPort, chip.usb.touchReset);
1321
+ flashNotes.push(`-- ${touch.note}`);
1322
+ if (touch.port) flashPort = touch.port;
1323
+ }
1324
+ const args = buildFlashArgs(buildDir, probe.runner, flashPort, flashRunner, probe.args);
1325
+
1326
+ // openocd flashes go through a dedicated session instead of `west flash`.
1327
+ // west's flow has three sequential races that each strand the board: the
1328
+ // connect happens against a running (often USB-active) application, the
1329
+ // erase precedes the write so the vector table is 0xFFFFFFFF while the
1330
+ // RAM algorithm runs (any exception → core LOCKUP at 0xFFFFFFFE, "timeout
1331
+ // waiting for algorithm"), and the trailing `reset run` frequently does
1332
+ // not reach the core. This session is deterministic end to end: halt at
1333
+ // the reset vector (static target for the DAP), mask interrupts for the
1334
+ // algorithm (exceptions cannot vector through erased flash), unmask
1335
+ // after, and boot the flashed app with a direct SYSRESETREQ. Falls back
1336
+ // to `west flash` when the image or session is unavailable.
1337
+ let westFallback = true;
1338
+ if (probe.runner === 'openocd') {
1339
+ const hex = join(buildDir, 'zephyr', 'zephyr.hex');
1340
+ if (existsSync(hex)) {
1341
+ // Forward slashes + TCL quoting so project paths with spaces work.
1342
+ const hexArg = `"${hex.replace(/\\/g, '/')}"`;
1343
+ // Target addressing: `cortex_m` is a PER-TARGET subcommand — a bare
1344
+ // `cortex_m maskisr on` is an unknown command (a silent no-op inside
1345
+ // catch). Resolve the session's target object once and address it.
1346
+ // Cortex-M-only, self-gating: on other cores the cortex_m method
1347
+ // errors and catch contains it (the plain flash path is safe there
1348
+ // without masking — the lockup class is Cortex-M vectoring).
1349
+ const flashed = openocdProbeSession(buildDir, zc, chip, [
1350
+ 'set _tgt [lindex [target names] 0]',
1351
+ 'reset halt',
1352
+ 'catch { $_tgt cortex_m maskisr on }',
1353
+ `flash write_image erase ${hexArg}`,
1354
+ 'catch { $_tgt cortex_m maskisr off }',
1355
+ // Boot the flashed app. Cortex-M: a direct SYSRESETREQ via AIRCR —
1356
+ // pin-independent, always reaches the core, and clears PRIMASK
1357
+ // (so the masked algorithm leaves nothing behind). Other cores:
1358
+ // openocd's generic reset run. A halted core STAYS halted across
1359
+ // a core-initiated reset (debug halt state survives — that is how
1360
+ // reset halt works), so resume it; on a running core resume errors
1361
+ // and the catch swallows it.
1362
+ 'if {[catch {$_tgt cortex_m maskisr on}] == 0} { $_tgt cortex_m maskisr off; mww 0xE000ED0C 0x05FA0004 } else { reset run }',
1363
+ 'sleep 100',
1364
+ 'catch { resume }',
1365
+ 'sleep 200',
1366
+ ]);
1367
+ if (flashed) {
1368
+ westFallback = false;
1369
+ flashNotes.push('-- probe flash ok: halt → masked write → SYSRESETREQ');
1370
+ }
1371
+ }
1372
+ }
1373
+
1374
+ let result: ReturnType<typeof spawnSync> | undefined;
1375
+ let raw = '';
1376
+ let ok = false;
1377
+ if (westFallback) {
1378
+ const inv = westSpawn(args, {
1379
+ cwd: projectRoot,
1380
+ encoding: 'utf-8',
1381
+ timeout: FLASH_TIMEOUT_MS,
1382
+ });
1383
+ result = spawnSync(inv.command, inv.args, inv.options);
1384
+ const fstdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
1385
+ const fstderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
1386
+ raw = fstdout + fstderr;
1387
+ ok = classifyUploadResult(probe.runner, result.status, raw);
1388
+ // A SUCCESSFUL west openocd flash can still leave the core HALTED:
1389
+ // west's trailing `reset run` does not reach a core behind an unwired
1390
+ // SRST, and the user's only recourse is the NRST button. Boot it from
1391
+ // a probe session instead — one core reset (a halted core re-halts at
1392
+ // the reset vector; a running core restarts the just-flashed app)
1393
+ // plus a resume.
1394
+ if (ok && probe.runner === 'openocd') {
1395
+ const booted = openocdProbeSession(buildDir, zc, chip, [
1396
+ 'set _tgt [lindex [target names] 0]',
1397
+ 'if {[catch {$_tgt cortex_m maskisr on}] == 0} { $_tgt cortex_m maskisr off; mww 0xE000ED0C 0x05FA0004 } else { reset run }',
1398
+ 'sleep 100',
1399
+ 'catch { resume }',
1400
+ 'sleep 200',
1401
+ ]);
1402
+ if (booted) flashNotes.push('-- probe boot ok: SYSRESETREQ → resume');
1403
+ }
1404
+ // Fallback-path recovery: a failed west flash leaves the core in
1405
+ // lockup; a direct SYSRESETREQ clears it so the caller's retry (or a
1406
+ // later flash) starts from a clean chip.
1407
+ if (!ok && probe.runner === 'openocd') {
1408
+ const revived = openocdProbeSession(buildDir, zc, chip, [
1409
+ 'init',
1410
+ 'set _tgt [lindex [target names] 0]',
1411
+ 'if {[catch {$_tgt cortex_m maskisr on}] == 0} { $_tgt cortex_m maskisr off; mww 0xE000ED0C 0x05FA0004 } else { reset run }',
1412
+ 'sleep 300',
1413
+ ]);
1414
+ if (revived) flashNotes.push(revived);
1415
+ // Known SWD-failure signatures get a recovery pointer — a board that
1416
+ // ignores SWD until power-cycled (low-power state, lockup, a wedged
1417
+ // probe) otherwise reads as a toolchain bug.
1418
+ if (isTargetSwdFailure(raw)) {
1419
+ flashNotes.push(
1420
+ '-- target ignored SWD — if a retry fails too: power-cycle the board, replug the probe, or skip SWD entirely (hold BOOT0, tap reset, re-run with --probe dfu)',
1421
+ );
1422
+ }
1423
+ }
1424
+ } else {
1425
+ ok = true;
1426
+ }
1427
+ return {
1428
+ success: ok,
1429
+ output: [...flashNotes, cleanseUploadOutput(probe.runner, westFallback ? result!.status : 0, raw)]
1430
+ .filter(Boolean).join('\n'),
1431
+ };
1432
+ },
1433
+
1434
+ monitor(o: ToolchainOptions): void {
1435
+ // Serial monitor over USB-CDC. Zephyr does NOT ship a `west serial`
1436
+ // subcommand (it's not a real west command — invoking it errors with
1437
+ // "unknown command"). The discovered west install's venv carries pyserial,
1438
+ // so run its bundled miniterm directly: `python -m serial.tools.miniterm`.
1439
+ // That is the same cross-platform terminal pyserial provides in ESP-IDF's
1440
+ // idf.py monitor, and it inherits stdio so Ctrl+C exits cleanly.
1441
+ if (!o.port) {
1442
+ throw new Error(
1443
+ 'A serial port is required to monitor. Pass --port <COMx/ttyX>.',
1444
+ );
1445
+ }
1446
+ // ESP32 USB-CDC console runs at 115200 (the Zephyr ESP32 board default).
1447
+ // The CLI's generic default of 9600 is wrong for this target; honor an
1448
+ // explicit --baud when given, else 115200.
1449
+ const baud = o.baud ?? 115200;
1450
+ const install = discoverWest();
1451
+ const py = install?.pythonExecutable ?? process.env.PYTHON ?? 'python';
1452
+ // Reuse west-spawn's env builder (prepends the venv bin dir to PATH so the
1453
+ // python we spawn resolves pyserial from the same venv). Falls back to the
1454
+ // process env when no install is discovered.
1455
+ const env = install ? buildEnv(install) : process.env;
1456
+ // Post-flash re-enumeration: the chip resets when `--upload` finishes, the
1457
+ // OS tears the serial device object down and re-creates it, and miniterm's
1458
+ // single open can land inside that window (a FileNotFoundError even though
1459
+ // the board never unplugged). Wait for the port to (re)appear first.
1460
+ if (!waitForSerialPort(py, env, projectRootFromOptions(o), o.port)) {
1461
+ process.exitCode = 1;
1462
+ return;
1463
+ }
1464
+ spawnSync(py, ['-m', 'serial.tools.miniterm', o.port, String(baud)], {
1465
+ cwd: projectRootFromOptions(o),
1466
+ env,
1467
+ stdio: 'inherit',
1468
+ });
1469
+ },
1470
+
1471
+ debug(o: ToolchainOptions): void {
1472
+ // Launch an interactive GDB session for the last build. The probe method
1473
+ // resolves exactly like flashing (zephyr.probe / zephyr.runner — the same
1474
+ // attach session, so the same quirks apply); debug-incapable methods
1475
+ // (bootloaders) are rejected with the debug-capable list. west debug
1476
+ // resolves the GDB binary from the build dir's CMakeCache — no
1477
+ // hand-authored gdbinit needed. Inherits stdio so GDB runs interactively.
1478
+ const projectRoot = projectRootFromOptions(o);
1479
+ const buildDir = join(projectRoot, 'build');
1480
+ const board = targetFromOptions(o);
1481
+ const zc = o.zephyrConfig as Record<string, unknown> | undefined;
1482
+ const probe = resolveProbeMethod(zc, chipForBuild(projectRoot, board), 'debug');
1483
+ if (!probe.ok) {
1484
+ console.error(`-- west debug: ${probe.error}`);
1485
+ process.exitCode = 1;
1486
+ return;
1487
+ }
1488
+ const debugArgs = ['debug', '-d', buildDir];
1489
+ if (probe.runner) debugArgs.push('--runner', probe.runner);
1490
+ debugArgs.push(...probe.args);
1491
+ const inv = westSpawn(debugArgs, {
1492
+ cwd: projectRoot,
1493
+ encoding: 'utf-8',
1494
+ stdio: 'inherit',
1495
+ });
1496
+ spawnSync(inv.command, inv.args, inv.options);
1497
+ },
1498
+
1499
+ debugServer(o: ToolchainOptions, action: 'start' | 'stop'): void {
1500
+ const projectRoot = projectRootFromOptions(o);
1501
+ const buildDir = join(projectRoot, 'build');
1502
+ const pidFile = join(projectRoot, '.typecad-hal', 'debug-server.pid');
1503
+
1504
+ if (action === 'stop') {
1505
+ stopDebugServer(pidFile);
1506
+ return;
1507
+ }
1508
+
1509
+ // start: wrap `west debugserver` as a long-running foreground process (the
1510
+ // VS Code background task owns this process; postDebugTask runs `stop`).
1511
+ if (!existsSync(join(buildDir, 'zephyr', 'runners.yaml'))) {
1512
+ console.error(
1513
+ `! No build at ${buildDir} — run 'npm run compile' (or F5's preLaunchTask) first.`,
1514
+ );
1515
+ process.exitCode = 1;
1516
+ return;
1517
+ }
1518
+ const stale = readStaleServerPid(pidFile);
1519
+ if (stale !== undefined) {
1520
+ // Already running (pid alive): just re-emit the ready marker so the
1521
+ // task's problem matcher completes immediately.
1522
+ console.log(`[typecad-hal] west debugserver already running (pid ${stale})`);
1523
+ console.log(`TYPECAD_HAL: debug server ready on ${DEBUG_SERVER_PORT}`);
1524
+ return;
1525
+ }
1526
+ try { rmSync(pidFile, { force: true }); } catch { /* already gone */ }
1527
+ // A wrapper/west that died without cleanup can leave openocd bound to
1528
+ // the gdb port with a stale pidfile — reclaim it or the new server
1529
+ // cannot bind (and gdb would attach to the orphan).
1530
+ const orphan = portOwnerPid(DEBUG_SERVER_PORT);
1531
+ if (orphan !== undefined && orphan !== process.pid) {
1532
+ console.log(`[typecad-hal] reclaiming orphaned debug server on :${DEBUG_SERVER_PORT} (pid ${orphan})`);
1533
+ killPidTree(orphan);
1534
+ }
1535
+
1536
+ // Runner + quirk parity with flash (resolveProbeMethod, same as `west
1537
+ // debug`): an explicit zephyr.probe selects the runner; either way, an
1538
+ // srst-based openocd cfg behind an unwired NRST (the probeRunnerQuirks
1539
+ // condition, read from the board's own method data) makes `reset init`
1540
+ // time out — the IDE's post-attach reset would hang the session. Apply
1541
+ // the core-reset override server-side unless the config's runnerArgs
1542
+ // already carry it (the create flow bakes it in).
1543
+ const board = targetFromOptions(o);
1544
+ const chip = chipForBuild(projectRoot, board);
1545
+ const zc = o.zephyrConfig as Record<string, unknown> | undefined;
1546
+ const probe = resolveProbeMethod(zc, chip, 'debug');
1547
+ // The runner west will actually drive: the explicit choice, else the
1548
+ // board's declared debug-runner default from the build's runners.yaml.
1549
+ const runner = ((probe.ok && probe.runner) || undefined)
1550
+ ?? readRunnersFacts(buildDir)?.debugRunner;
1551
+ const isOcd = runner === undefined || runner === 'openocd' || runner.startsWith('openocd');
1552
+ if (!isOcd) {
1553
+ console.warn(`! debug-server: the IDE wiring (ready marker, quirk args) targets the ` +
1554
+ `openocd runner; this build's debug runner is '${runner}'. Starting it plain — ` +
1555
+ `the F5 session may not connect.`);
1556
+ }
1557
+ const serverArgs = ['debugserver', '-d', buildDir];
1558
+ if (probe.ok) {
1559
+ if (probe.runner) serverArgs.push('--runner', probe.runner);
1560
+ serverArgs.push(...probe.args);
1561
+ }
1562
+ if (isOcd) {
1563
+ serverArgs.push(
1564
+ '--gdb-port', String(DEBUG_SERVER_PORT),
1565
+ // Pinned so the readiness poll below has a deterministic port.
1566
+ '--tcl-port', String(DEBUG_SERVER_TCL_PORT),
1567
+ // The board's own openocd.cfg may declare gdb-attach/gdb-detach
1568
+ // events (reset-on-attach for standalone sessions). Under an
1569
+ // IDE-managed session a stop event mid-initialization aborts
1570
+ // debugger setup, so neutralize them: west's --cmd-pre-init lands
1571
+ // AFTER the cfg files in the openocd command line, so these win.
1572
+ '--cmd-pre-init', '$_TARGETNAME configure -event gdb-attach {}',
1573
+ '--cmd-pre-init', '$_TARGETNAME configure -event gdb-detach {}',
1574
+ );
1575
+ const method = (zc?.probe as string | undefined)
1576
+ ? chip.probeMethods?.find((m) => m.id === zc?.probe)
1577
+ : chip.probeMethods?.find((m) => m.debug !== false);
1578
+ const cfg = method?.debugCfg ?? [];
1579
+ const srst = cfg.some((l) => /reset_config\s+srst/.test(l));
1580
+ const connectAssert = cfg.some((l) => /connect_assert_srst/.test(l));
1581
+ if (method?.runner === 'openocd' && srst && !connectAssert
1582
+ && !(probe.ok && probe.args.includes('--cmd-pre-init=reset_config none'))) {
1583
+ serverArgs.push('--cmd-pre-init', 'reset_config none');
1584
+ }
1585
+ }
1586
+ const inv = westSpawn(serverArgs, { cwd: projectRoot });
1587
+ const child = spawn(inv.command, inv.args, {
1588
+ ...inv.options,
1589
+ stdio: ['ignore', 'pipe', 'pipe'],
1590
+ // Own process group on POSIX so `stop` can signal the whole tree.
1591
+ ...(process.platform !== 'win32' ? { detached: true } : {}),
1592
+ });
1593
+ // @types/node 26 types ChildProcess's on/once through the internal
1594
+ // InternalEventEmitter base. When `tsc -b` rechecks this package in the
1595
+ // same solution pass that rebuilds its project references, that
1596
+ // inheritance can fail to surface and the spawned child's type loses
1597
+ // .on/.once (TS2339) — while stream types, which COPY the event
1598
+ // signatures, keep working. Subscribe through a structural copy of the
1599
+ // one signature this file needs: the copy-don't-inherit prescription
1600
+ // @types/node itself applies to multi-level emitter classes. The `unknown`
1601
+ // hop keeps the cast legal in both resolution states.
1602
+ const childExit = child as unknown as {
1603
+ on(event: 'exit', listener: (code: number | null) => void): unknown;
1604
+ once(event: 'exit', listener: (code: number | null) => void): unknown;
1605
+ };
1606
+ mkdirSync(join(projectRoot, '.typecad-hal'), { recursive: true });
1607
+ writeFileSync(pidFile, String(child.pid), 'utf-8');
1608
+ console.log(`[typecad-hal] starting west debugserver (gdb on localhost:${DEBUG_SERVER_PORT})`);
1609
+ // A reader that goes away (closed task terminal, piped head) must not
1610
+ // take the server down with an EPIPE.
1611
+ process.stdout?.on?.('error', () => { /* EPIPE — server keeps running */ });
1612
+ process.stderr?.on?.('error', () => { /* EPIPE — server keeps running */ });
1613
+ child.stdout?.on('data', (d: Buffer) => process.stdout.write(d));
1614
+ child.stderr?.on('data', (d: Buffer) => process.stderr.write(d));
1615
+
1616
+ // Ready = the TCL port accepts connections. NOT the gdb port: openocd's
1617
+ // gdb server takes ONE client, so a TCP probe there both logs
1618
+ // "attempted 'gdb' connection rejected" and can race the real gdb
1619
+ // connection for the slot. The tcl listener opens at the END of openocd
1620
+ // init (after the gdb listener and the startup halt) — a truer signal —
1621
+ // and probes there are inert.
1622
+ const deadline = Date.now() + DEBUG_SERVER_START_TIMEOUT_MS;
1623
+ const poll = (): void => {
1624
+ const sock = netConnect(DEBUG_SERVER_TCL_PORT, '127.0.0.1');
1625
+ sock.once('connect', () => {
1626
+ sock.destroy();
1627
+ console.log(`TYPECAD_HAL: debug server ready on ${DEBUG_SERVER_PORT}`);
1628
+ });
1629
+ sock.once('error', () => {
1630
+ sock.destroy();
1631
+ if (child.exitCode !== null) return; // server died — exit handler reports
1632
+ if (Date.now() > deadline) {
1633
+ console.error(`! west debugserver did not open :${DEBUG_SERVER_TCL_PORT} within `
1634
+ + `${DEBUG_SERVER_START_TIMEOUT_MS / 1000}s — see its output above.`);
1635
+ stopDebugServer(pidFile);
1636
+ process.exitCode = 1;
1637
+ return;
1638
+ }
1639
+ setTimeout(poll, 250);
1640
+ });
1641
+ };
1642
+ poll();
1643
+
1644
+ childExit.on('exit', (code) => {
1645
+ try { rmSync(pidFile, { force: true }); } catch { /* already gone */ }
1646
+ // Exit before ready: surface as a task failure (the debugger never
1647
+ // connects and VS Code reports the background task's non-zero exit).
1648
+ if (code !== null && code !== 0) process.exitCode = code;
1649
+ });
1650
+ const forwardSignal = (): void => {
1651
+ stopDebugServer(pidFile);
1652
+ childExit.once('exit', () => process.exit(0));
1653
+ setTimeout(() => process.exit(0), 1500).unref();
1654
+ };
1655
+ process.on('SIGINT', forwardSignal);
1656
+ process.on('SIGTERM', forwardSignal);
1657
+ },
1658
+ };
1659
+
1660
+ /** How long `debug-server start` waits for the gdb port before failing. */
1661
+ const DEBUG_SERVER_START_TIMEOUT_MS = 45_000;
1662
+
1663
+ /**
1664
+ * Read the pidfile and return the pid when that process is still alive,
1665
+ * undefined otherwise (no file, dead pid, or unparseable). Best-effort.
1666
+ */
1667
+ function readStaleServerPid(pidFile: string): number | undefined {
1668
+ try {
1669
+ const pid = Number.parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
1670
+ if (!Number.isInteger(pid)) return undefined;
1671
+ process.kill(pid, 0); // throws ESRCH when dead
1672
+ return pid;
1673
+ } catch {
1674
+ return undefined;
1675
+ }
1676
+ }
1677
+
1678
+ /**
1679
+ * The pid of whatever process is LISTENING on the gdb port — the recovery
1680
+ * path for orphaned servers (the wrapper and west can die while openocd
1681
+ * survives, e.g. a killed task terminal; the pidfile is then stale but the
1682
+ * port stays bound and the next session would attach to the orphan).
1683
+ * Best-effort: netstat on Windows, lsof on POSIX; undefined when the port is
1684
+ * free or the platform tool is unavailable.
1685
+ */
1686
+ function portOwnerPid(port: number): number | undefined {
1687
+ try {
1688
+ if (process.platform === 'win32') {
1689
+ const out = spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf-8' });
1690
+ if (out.status !== 0) return undefined;
1691
+ for (const line of (out.stdout ?? '').split(/\r?\n/)) {
1692
+ const cols = line.trim().split(/\s+/);
1693
+ if (cols.length >= 5 && cols[3] === 'LISTENING'
1694
+ && cols[1].endsWith(`:${port}`)) {
1695
+ const pid = Number.parseInt(cols[4], 10);
1696
+ if (Number.isInteger(pid)) return pid;
1697
+ }
1698
+ }
1699
+ return undefined;
1700
+ }
1701
+ const out = spawnSync('lsof', ['-ti', `tcp:${port}`], { encoding: 'utf-8' });
1702
+ if (out.status !== 0 || !out.stdout?.trim()) return undefined;
1703
+ const pid = Number.parseInt(out.stdout.trim().split(/\s+/)[0]!, 10);
1704
+ return Number.isInteger(pid) ? pid : undefined;
1705
+ } catch {
1706
+ return undefined;
1707
+ }
1708
+ }
1709
+
1710
+ /** Kill a pid tree (Windows: taskkill /T; POSIX: the process group). */
1711
+ function killPidTree(pid: number): void {
1712
+ if (process.platform === 'win32') {
1713
+ // /T: the whole tree (the pid may be the micromamba/west wrapper; openocd
1714
+ // is its grandchild). /F: force — the server has no stdin to close.
1715
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf-8' });
1716
+ } else {
1717
+ try {
1718
+ process.kill(-pid, 'SIGTERM'); // the detached process group
1719
+ } catch {
1720
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
1721
+ }
1722
+ }
1723
+ }
1724
+
1725
+ /** Kill the debug server tree (micromamba/west → openocd) and drop the pidfile.
1726
+ * Falls back to the gdb-port owner when the recorded pid is already dead —
1727
+ * that orphan would otherwise serve stale sessions forever. */
1728
+ function stopDebugServer(pidFile: string): void {
1729
+ const pid = readStaleServerPid(pidFile) ?? portOwnerPid(DEBUG_SERVER_PORT);
1730
+ if (pid === undefined) {
1731
+ try { rmSync(pidFile, { force: true }); } catch { /* already gone */ }
1732
+ console.log('[typecad-hal] debug server not running');
1733
+ return;
1734
+ }
1735
+ killPidTree(pid);
1736
+ try { rmSync(pidFile, { force: true }); } catch { /* already gone */ }
1737
+ console.log(`[typecad-hal] debug server stopped (pid ${pid})`);
1738
+ }
1739
+
1740
+ // -- serial monitor: post-flash re-enumeration wait --------------------------
1741
+
1742
+ /** How long `--monitor` waits for the port to (re)appear after a flash reset. */
1743
+ export const MONITOR_PORT_WAIT_MS = 8000;
1744
+
1745
+ /**
1746
+ * The pyserial port wait run before miniterm (argv built by
1747
+ * serialPortWaitArgs). Polls list_ports — never OPENS the port, because
1748
+ * toggling DTR on an open can itself reset some boards — until the device
1749
+ * name matches case-insensitively or the timeout elapses. Exit codes:
1750
+ * 0 port present, 1 timeout (message printed to stderr), 2 pyserial
1751
+ * unavailable (the caller lets miniterm surface the real error instead of
1752
+ * reporting a bogus timeout).
1753
+ */
1754
+ const SERIAL_PORT_WAIT_PY = [
1755
+ 'import sys, time',
1756
+ 'try:',
1757
+ ' from serial.tools import list_ports',
1758
+ 'except Exception:',
1759
+ ' sys.exit(2)',
1760
+ 'port, timeout = sys.argv[1], float(sys.argv[2])',
1761
+ 'def present():',
1762
+ ' return any(d.device.lower() == port.lower() for d in list_ports.comports())',
1763
+ 'if not present():',
1764
+ " print('waiting for %s to re-enumerate (the board resets after flashing)...' % port, flush=True)",
1765
+ ' end = time.time() + timeout',
1766
+ ' while not present():',
1767
+ ' if time.time() >= end:',
1768
+ " print('%s did not come back within %ds - some boards re-enumerate under a different port name' % (port, int(timeout)), file=sys.stderr, flush=True)",
1769
+ ' sys.exit(1)',
1770
+ ' time.sleep(0.25)',
1771
+ ].join('\n');
1772
+
1773
+ /** argv for the pyserial port wait — exported for the toolchain unit tests. */
1774
+ export function serialPortWaitArgs(port: string, timeoutMs: number): string[] {
1775
+ return ['-c', SERIAL_PORT_WAIT_PY, port, String(timeoutMs / 1000)];
1776
+ }
1777
+
1778
+ /**
1779
+ * Wait for `port` to be listed by the venv's pyserial before miniterm opens
1780
+ * it once. True = proceed to miniterm (port present, or the wait itself was
1781
+ * best-effort-skipped — a missing python/pyserial lets miniterm show the
1782
+ * underlying failure); false = the port never came back (message printed).
1783
+ */
1784
+ function waitForSerialPort(py: string, env: NodeJS.ProcessEnv, cwd: string, port: string): boolean {
1785
+ const res = spawnSync(py, serialPortWaitArgs(port, MONITOR_PORT_WAIT_MS), {
1786
+ cwd,
1787
+ env,
1788
+ stdio: 'inherit',
1789
+ });
1790
+ if (res.error || res.status === null || res.status === 2) return true;
1791
+ return res.status === 0;
1792
+ }