@typecad/framework-zephyr 1.0.0-alpha.13 → 1.0.0-alpha.15

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 (191) hide show
  1. package/README.md +22 -35
  2. package/dist/as-built.d.ts +33 -0
  3. package/dist/as-built.js +188 -0
  4. package/dist/async/timer-polyfill.js +1 -1
  5. package/dist/boardgen.d.ts +128 -0
  6. package/dist/boardgen.js +1366 -0
  7. package/dist/chips/board-overrides.d.ts +7 -0
  8. package/dist/chips/board-overrides.js +11 -0
  9. package/dist/chips/controllers.d.ts +28 -8
  10. package/dist/chips/controllers.js +49 -12
  11. package/dist/chips/esp32s3.js +49 -0
  12. package/dist/chips/index.d.ts +6 -12
  13. package/dist/chips/index.js +18 -34
  14. package/dist/chips/resolve.d.ts +16 -2
  15. package/dist/chips/resolve.js +298 -15
  16. package/dist/chips/soc/.d.ts +2 -0
  17. package/dist/chips/soc/.js +129 -0
  18. package/dist/chips/soc/esp32.d.ts +2 -0
  19. package/dist/chips/soc/esp32.js +120 -0
  20. package/dist/chips/soc/esp32c3.d.ts +2 -0
  21. package/dist/chips/soc/esp32c3.js +90 -0
  22. package/dist/chips/soc/esp32c6.d.ts +2 -0
  23. package/dist/chips/soc/esp32c6.js +109 -0
  24. package/dist/chips/soc/esp32s3.d.ts +2 -0
  25. package/dist/chips/soc/esp32s3.js +189 -0
  26. package/dist/chips/soc/index.d.ts +2 -0
  27. package/dist/chips/soc/index.js +23 -0
  28. package/dist/chips/soc/nrf52840.d.ts +2 -0
  29. package/dist/chips/soc/nrf52840.js +130 -0
  30. package/dist/chips/soc/rp2040.d.ts +2 -0
  31. package/dist/chips/soc/rp2040.js +141 -0
  32. package/dist/chips/soc/rp2350a.d.ts +2 -0
  33. package/dist/chips/soc/rp2350a.js +145 -0
  34. package/dist/chips/soc/samd21g18a.d.ts +2 -0
  35. package/dist/chips/soc/samd21g18a.js +143 -0
  36. package/dist/chips/soc/stm32f411xe.d.ts +2 -0
  37. package/dist/chips/soc/stm32f411xe.js +251 -0
  38. package/dist/chips/types.d.ts +382 -12
  39. package/dist/chips/xiao-ble.js +33 -0
  40. package/dist/display/profiles.js +1 -1
  41. package/dist/doctor.js +38 -4
  42. package/dist/dt-config/custom-board.d.ts +23 -0
  43. package/dist/dt-config/custom-board.js +227 -0
  44. package/dist/dt-config/kconfig.d.ts +83 -1
  45. package/dist/dt-config/kconfig.js +116 -27
  46. package/dist/dt-config/overlay.js +623 -18
  47. package/dist/framework.manifest.d.ts +20 -30
  48. package/dist/framework.manifest.js +124 -178
  49. package/dist/index.d.ts +4 -1
  50. package/dist/index.js +8 -2
  51. package/dist/lowering/adc.d.ts +26 -5
  52. package/dist/lowering/adc.js +137 -31
  53. package/dist/lowering/ble.js +31 -28
  54. package/dist/lowering/dac.js +9 -9
  55. package/dist/lowering/fs.js +18 -10
  56. package/dist/lowering/gpio.d.ts +2 -0
  57. package/dist/lowering/gpio.js +90 -10
  58. package/dist/lowering/http.js +39 -14
  59. package/dist/lowering/hwtimer.d.ts +4 -0
  60. package/dist/lowering/hwtimer.js +35 -10
  61. package/dist/lowering/i2c.js +46 -54
  62. package/dist/lowering/index.d.ts +5 -5
  63. package/dist/lowering/index.js +15 -16
  64. package/dist/lowering/interrupts.d.ts +22 -5
  65. package/dist/lowering/interrupts.js +104 -22
  66. package/dist/lowering/mqtt.js +12 -6
  67. package/dist/lowering/power.js +0 -4
  68. package/dist/lowering/preferences.js +49 -77
  69. package/dist/lowering/pulse.js +5 -36
  70. package/dist/lowering/pwm.d.ts +32 -3
  71. package/dist/lowering/pwm.js +105 -17
  72. package/dist/lowering/random.js +3 -3
  73. package/dist/lowering/sensor.d.ts +37 -0
  74. package/dist/lowering/sensor.js +119 -0
  75. package/dist/lowering/spi.d.ts +12 -0
  76. package/dist/lowering/spi.js +65 -40
  77. package/dist/lowering/thread.d.ts +14 -0
  78. package/dist/lowering/thread.js +55 -0
  79. package/dist/lowering/timing.js +20 -46
  80. package/dist/lowering/tone.js +18 -5
  81. package/dist/lowering/uart.d.ts +13 -0
  82. package/dist/lowering/uart.js +72 -51
  83. package/dist/lowering/usb.d.ts +34 -0
  84. package/dist/lowering/usb.js +206 -0
  85. package/dist/lowering/wdt.d.ts +1 -1
  86. package/dist/lowering/wdt.js +17 -7
  87. package/dist/lowering/wifi.d.ts +3 -2
  88. package/dist/lowering/wifi.js +120 -111
  89. package/dist/sdk/board-catalog-sync.d.ts +16 -0
  90. package/dist/sdk/board-catalog-sync.js +41 -0
  91. package/dist/sdk/board-data.generated.d.ts +2 -0
  92. package/dist/sdk/board-data.generated.js +4 -0
  93. package/dist/sdk/catalog-walker.d.ts +90 -0
  94. package/dist/sdk/catalog-walker.js +682 -0
  95. package/dist/sdk/dts-reader.d.ts +83 -0
  96. package/dist/sdk/dts-reader.js +596 -0
  97. package/dist/strategy.d.ts +136 -17
  98. package/dist/strategy.js +881 -212
  99. package/dist/toolchain/bossac-touch.d.ts +18 -0
  100. package/dist/toolchain/bossac-touch.js +88 -0
  101. package/dist/toolchain/compat.d.ts +2 -2
  102. package/dist/toolchain/compat.js +8 -3
  103. package/dist/toolchain/debug-config.d.ts +21 -26
  104. package/dist/toolchain/debug-config.js +212 -72
  105. package/dist/toolchain/env-check.d.ts +4 -4
  106. package/dist/toolchain/env-check.js +5 -5
  107. package/dist/toolchain/index.d.ts +66 -1
  108. package/dist/toolchain/index.js +713 -58
  109. package/dist/toolchain/scaffold.d.ts +9 -0
  110. package/dist/toolchain/scaffold.js +84 -7
  111. package/dist/toolchain/west-discover.d.ts +8 -1
  112. package/dist/toolchain/west-discover.js +19 -6
  113. package/dist/toolchain/west-spawn.js +17 -5
  114. package/installer/CHANGELOG.md +473 -0
  115. package/installer/README.md +328 -0
  116. package/installer/environment.yml +46 -0
  117. package/installer/etc/conda/activate.d/zephyr.bat +7 -0
  118. package/installer/etc/conda/activate.d/zephyr.ps1 +12 -0
  119. package/installer/etc/conda/activate.d/zephyr.sh +20 -0
  120. package/installer/etc/conda/deactivate.d/zephyr.bat +6 -0
  121. package/installer/etc/conda/deactivate.d/zephyr.ps1 +6 -0
  122. package/installer/etc/conda/deactivate.d/zephyr.sh +4 -0
  123. package/installer/install.mjs +580 -0
  124. package/installer/install.ps1 +619 -0
  125. package/installer/install.sh +310 -0
  126. package/installer/lib/detect-platform.sh +62 -0
  127. package/installer/lib/fetch-sdk.sh +304 -0
  128. package/installer/lib/init-workspace.sh +153 -0
  129. package/installer/lib/write-activation.sh +76 -0
  130. package/installer/patches/tfm-c-include-separators.patch +19 -0
  131. package/installer/patches/zephyr-tfm-nordic-zephyr-include.patch +24 -0
  132. package/installer/templates/project/.typecad/activate-zephyr.ps1 +31 -0
  133. package/installer/templates/project/.typecad/activate-zephyr.sh +31 -0
  134. package/installer/templates/project/.vscode/settings.json +30 -0
  135. package/installer/templates/project/README.md +58 -0
  136. package/installer/versions.env +110 -0
  137. package/package.json +16 -16
  138. package/src/as-built.ts +206 -0
  139. package/src/boardgen.ts +1428 -0
  140. package/src/chips/controllers.ts +61 -12
  141. package/src/chips/index.ts +35 -54
  142. package/src/chips/resolve.ts +316 -14
  143. package/src/chips/types.ts +388 -13
  144. package/src/display/profiles.ts +1 -1
  145. package/src/doctor.ts +110 -77
  146. package/src/dt-config/custom-board.ts +325 -0
  147. package/src/dt-config/kconfig.ts +177 -19
  148. package/src/dt-config/overlay.ts +1058 -450
  149. package/src/framework.manifest.ts +125 -178
  150. package/src/index.ts +57 -41
  151. package/src/lowering/adc.ts +222 -102
  152. package/src/lowering/ble.ts +32 -28
  153. package/src/lowering/dac.ts +82 -82
  154. package/src/lowering/fs.ts +18 -10
  155. package/src/lowering/gpio.ts +242 -152
  156. package/src/lowering/http.ts +512 -488
  157. package/src/lowering/hwtimer.ts +139 -101
  158. package/src/lowering/i2c.ts +116 -126
  159. package/src/lowering/index.ts +91 -93
  160. package/src/lowering/interrupts.ts +113 -21
  161. package/src/lowering/mqtt.ts +12 -6
  162. package/src/lowering/preferences.ts +327 -354
  163. package/src/lowering/pwm.ts +167 -74
  164. package/src/lowering/random.ts +3 -3
  165. package/src/lowering/sensor.ts +155 -0
  166. package/src/lowering/spi.ts +164 -124
  167. package/src/lowering/thread.ts +63 -0
  168. package/src/lowering/timing.ts +46 -72
  169. package/src/lowering/uart.ts +73 -51
  170. package/src/lowering/usb.ts +221 -0
  171. package/src/lowering/wdt.ts +95 -84
  172. package/src/lowering/wifi.ts +120 -111
  173. package/src/sdk/board-catalog-sync.ts +62 -0
  174. package/src/strategy.ts +888 -229
  175. package/src/toolchain/bossac-touch.ts +112 -0
  176. package/src/toolchain/compat.ts +8 -3
  177. package/src/toolchain/debug-config.ts +666 -522
  178. package/src/toolchain/env-check.ts +9 -9
  179. package/src/toolchain/index.ts +765 -44
  180. package/src/toolchain/scaffold.ts +81 -7
  181. package/src/toolchain/west-discover.ts +334 -316
  182. package/src/toolchain/west-spawn.ts +17 -5
  183. package/src/async/timer-polyfill.ts +0 -107
  184. package/src/chips/esp32.ts +0 -74
  185. package/src/chips/esp32s3.ts +0 -57
  186. package/src/chips/xiao-ble.ts +0 -70
  187. package/src/lowering/power.ts +0 -70
  188. package/src/lowering/pulse.ts +0 -93
  189. package/src/lowering/tone.ts +0 -60
  190. package/src/lowering/worker-backing.ts +0 -94
  191. package/src/lowering/worker.ts +0 -17
@@ -20,27 +20,265 @@
20
20
 
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { basename, dirname, join } from 'node:path';
23
- import { readdirSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
23
+ import { readdirSync, readFileSync, mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
24
24
  import type { ToolchainOptions, CompileResult, UploadResult } from '@typecad/cuttlefish/api/shared';
25
25
  import { parseCompileErrors } from '@typecad/cuttlefish/api/shared';
26
- import { scaffoldZephyrProject, writeIfChanged } from './scaffold.js';
26
+ import { scaffoldZephyrProject, writeIfChanged, appendLibraryOverlayFragments } from './scaffold.js';
27
+ import { parseZephyrDts, asBuiltJson } from '../as-built.js';
27
28
  import { westSpawn, buildEnv } from './west-spawn.js';
28
29
  import { discoverWest } from './west-discover.js';
29
30
  import { writeDebugConfig, resolveDebugLocations } from './debug-config.js';
31
+ import { bossacTouchReset } from './bossac-touch.js';
30
32
  import { ZephyrStrategy } from '../strategy.js';
31
33
  import { generateOverlay, type DisplayWiring, type TouchWiring, type OverlayDiagnostic } from '../dt-config/overlay.js';
32
- import { chipForTarget } from '../chips/index.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';
33
39
  import { detectZephyrVersion, checkZephyrCompat, resolveBoardTarget } from './compat.js';
34
40
  import { DEFAULT_ZEPHYR_DISPLAY_PROFILE } from '../display/profiles.js';
35
41
 
36
- /** Default board target — the framework's MVP canonical board. */
37
- const DEFAULT_BOARD = 'xiao_ble';
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
+ }
38
268
 
39
269
  function targetFromOptions(o: ToolchainOptions): string {
40
- // The cuttlefish CLI populates ToolchainOptions.buildTarget from
41
- // config.frameworkData.buildTarget. Accept frameworkData.target as an alias.
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.
42
274
  const fcTarget = (o.frameworkConfig?.target as string | undefined);
43
- return (o.buildTarget as string | undefined) ?? fcTarget ?? DEFAULT_BOARD;
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;
44
282
  }
45
283
 
46
284
  /**
@@ -84,19 +322,230 @@ const FLASH_TIMEOUT_MS = 120_000;
84
322
  * Exported (pure) so the runner-selection contract is unit-testable without
85
323
  * spawning west.
86
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
+ function openocdProbeSession(
406
+ buildDir: string,
407
+ zc: Record<string, unknown> | undefined,
408
+ chip: ZephyrChipDescriptor,
409
+ commands: readonly string[],
410
+ ): string | undefined {
411
+ // Config resolution — two sources, in order:
412
+ // 1. The named probe method's verbatim debugCfg from the board catalog
413
+ // (written to a temp cfg), when zephyr.probe names a method that has
414
+ // one.
415
+ // 2. The board's own support/openocd.cfg in the Zephyr tree — the exact
416
+ // config `west flash` resolves for the openocd runner. This covers
417
+ // raw `zephyr.runner: 'openocd'` (no named probe) and probe methods
418
+ // that ship no debugCfg of their own.
419
+ const probeId = zc?.probe as string | undefined;
420
+ const method = chip.probeMethods?.find((m) => m.id === probeId);
421
+ const cfgLines = method?.debugCfg;
422
+ // Session reset policy: `reset_config none`. The session's resets are
423
+ // core-domain by design — vector-catch halt before the flash write,
424
+ // SYSRESETREQ to boot — so they must not depend on the SRST pin. Boards
425
+ // like the WeAct Black Pill don't break NRST out at all: under the board
426
+ // cfg's `srst_only`, every `reset` asserts a pin that reaches nothing
427
+ // (the target never resets, `reset halt` catches the core mid-app in
428
+ // dirty state and the flash algorithm times out) while the probe's
429
+ // floating SRST sense reports phantom "external reset detected" events
430
+ // that leave the session's halt state inconsistent. Method-declared west
431
+ // quirks (`--cmd-pre-init=…`) are appended after and override the
432
+ // default for boards whose facts carry one.
433
+ const preInit = [
434
+ 'reset_config none',
435
+ ...(method?.args ?? [])
436
+ .filter((a) => a.startsWith('--cmd-pre-init='))
437
+ .map((a) => a.slice('--cmd-pre-init='.length)),
438
+ ];
439
+
440
+ const install = discoverWest();
441
+ const sdkRoot = process.env.ZEPHYR_SDK_INSTALL_DIR || install?.sdkInstallDir;
442
+ if (!sdkRoot) return undefined;
443
+ const openocdExe = join(sdkRoot, 'hosttools', 'openocd', 'bin',
444
+ process.platform === 'win32' ? 'openocd.exe' : 'openocd');
445
+ if (!existsSync(openocdExe)) return undefined;
446
+ // Script search path: the SDK layouts differ across versions — prefer the
447
+ // share/ form west's own runner uses, fall back to the scripts/ form.
448
+ const shareScripts = join(sdkRoot, 'hosttools', 'openocd', 'share', 'openocd', 'scripts');
449
+ const binScripts = join(sdkRoot, 'hosttools', 'openocd', 'scripts');
450
+ const searchDir = existsSync(shareScripts) ? shareScripts : binScripts;
451
+
452
+ let cfgArgs: string[] | undefined;
453
+ let sessionCfg: string | undefined;
454
+ if (cfgLines && cfgLines.length > 0) {
455
+ sessionCfg = join(buildDir, 'cuttlefish-probe.cfg');
456
+ } else {
457
+ // The board target's qualifier ('blackpill_f411ce/stm32f411xe' →
458
+ // 'blackpill_f411ce') identifies the board dir; the vendor segment is
459
+ // not part of the target, so probe the boards/ tree for it.
460
+ const zephyrBase = process.env.ZEPHYR_BASE || install?.zephyrBase;
461
+ const boardDir = (chip.id ?? '').split('/')[0];
462
+ if (!zephyrBase || !boardDir) return undefined;
463
+ const boardsRoot = join(zephyrBase, 'boards');
464
+ let supportCfg: string | undefined;
465
+ try {
466
+ for (const vendor of readdirSync(boardsRoot)) {
467
+ const candidate = join(boardsRoot, vendor, boardDir, 'support', 'openocd.cfg');
468
+ if (existsSync(candidate)) { supportCfg = candidate; break; }
469
+ }
470
+ } catch {
471
+ return undefined;
472
+ }
473
+ if (!supportCfg) return undefined;
474
+ cfgArgs = ['-s', dirname(supportCfg), '-f', supportCfg];
475
+ }
476
+
477
+ try {
478
+ mkdirSync(buildDir, { recursive: true });
479
+ if (sessionCfg) {
480
+ writeFileSync(sessionCfg, cfgLines!.join('\n') + '\n', 'utf-8');
481
+ cfgArgs = ['-f', sessionCfg];
482
+ }
483
+ const res = spawnSync(openocdExe, [
484
+ '-s', searchDir, ...cfgArgs!,
485
+ // Pre-init TCL AFTER the cfg (overrides its reset_config) and BEFORE
486
+ // init — the same position west gives --cmd-pre-init.
487
+ ...preInit.map((c) => ['-c', c] as [string, string]).flat(),
488
+ '-c', 'init',
489
+ ...commands.map((c) => ['-c', c]).flat(),
490
+ '-c', 'shutdown',
491
+ ], {
492
+ cwd: buildDir,
493
+ encoding: 'utf-8' as const,
494
+ timeout: 20_000,
495
+ });
496
+ return res.status === 0 ? `-- probe session ok: ${commands.join('; ')}` : undefined;
497
+ } catch {
498
+ return undefined;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Whether a flash runner carries the upload over a serial port. Runner-gated,
504
+ * never board-name-gated: esptool and bossac are the only runners
505
+ * `buildFlashArgs` forwards `--port` to, so they are the only ones that
506
+ * cannot flash without one. Probe runners (openocd, jlink) and USB flows
507
+ * (dfu-util, uf2 mass storage) need no port — a missing `--port` must not
508
+ * block them.
509
+ *
510
+ * Exported (pure) so the port-requirement contract is unit-testable without
511
+ * spawning west.
512
+ */
513
+ export function uploadRequiresPort(runner: string | undefined): boolean {
514
+ return runner === 'esptool' || runner === 'bossac';
515
+ }
516
+
87
517
  export function buildFlashArgs(
88
518
  buildDir: string,
89
- board: string,
90
519
  userRunner: string | undefined,
91
520
  port: string | undefined,
521
+ flashRunner?: string,
522
+ runnerArgs?: readonly string[],
92
523
  ): string[] {
524
+ // flashRunner is the runner the flash will actually use — the explicit
525
+ // zephyr.runner when set, else the board's declared default from its probe
526
+ // table. It gates the esptool port forwarding below; only an EXPLICIT
527
+ // userRunner forces west's --runner (board defaults stay board.cmake's
528
+ // choice). Runner-gated, never board-name-gated: any board whose flash
529
+ // runs esptool gets the same forwarding.
93
530
  const args = ['flash', '-d', buildDir];
94
531
  if (userRunner) {
95
532
  args.push('--runner', userRunner);
96
533
  }
97
- if (port && board.startsWith('esp32')) {
534
+ if (port && flashRunner === 'esptool') {
98
535
  args.push('--esp-device', port);
99
536
  }
537
+ // The bossac runner defaults its port to /dev/ttyACM0 — on Windows that
538
+ // never matches, so the port MUST be forwarded or bossac fails with
539
+ // "No device found on /dev/ttyACM0" (same class of port-forwarding
540
+ // problem as the esptool --esp-device above).
541
+ if (port && flashRunner === 'bossac') {
542
+ args.push('--bossac-port', port);
543
+ }
544
+ // Extra runner-specific flags, appended verbatim (west's runner parsers
545
+ // accept them after the runner is selected).
546
+ if (runnerArgs && runnerArgs.length > 0) {
547
+ args.push(...runnerArgs);
548
+ }
100
549
  return args;
101
550
  }
102
551
 
@@ -207,8 +656,6 @@ export const Toolchain = {
207
656
  // logic in scaffold via the usage scan. Mirrors how Arduino's library
208
657
  // resolution is a pre-build artifact step.
209
658
  const projectRoot = basename(outputDir) === 'src' ? dirname(outputDir) : outputDir;
210
- const board = DEFAULT_BOARD;
211
- const chip = chipForTarget(board);
212
659
  // Scan the emitted source for usage tokens (same authoritative signal the
213
660
  // scaffold uses). entryPoint is the path to main.cpp; its dir is src/.
214
661
  const srcDir = dirname(entryPoint);
@@ -221,6 +668,12 @@ export const Toolchain = {
221
668
  }
222
669
  } catch { /* src may not exist yet on first prepare */ }
223
670
  const uses = (t: string): boolean => src.includes(t);
671
+ // Inline-override markers (the escape hatch): the lowerings emit
672
+ // `cuttlefish-user-facts` comments carrying routing the transpiler
673
+ // cannot synthesize (adc device/pinctrl, pwm controller/channel).
674
+ // Merged into the chip BEFORE the scans + overlay generation, so the DT
675
+ // nodes, pinctrl groups, and used-pin recovery treat them as facts.
676
+ const chip = applyUserFactMarkers(chipForBuild(projectRoot, ''), src);
224
677
  // Display usage tokens: the minimal GFX runtime (display_write/_fill_rect)
225
678
  // and the UI display adapter (display_init / __tc_display_dev /
226
679
  // DEVICE_DT_GET on the display nodelabel). Both paths need the DT overlay
@@ -237,17 +690,65 @@ export const Toolchain = {
237
690
  // references (FT6336U on I2C, XPT2046 on the display's SPI bus).
238
691
  const usesTouch = uses('ft6336u') || uses('touch_');
239
692
  const usesXpt = uses('xpt2046');
693
+ const sensorParts = scanSensorParts(src);
694
+ const spiTargetParts = scanSpiTargets(src);
240
695
  const overlay = generateOverlay(chip, {
241
- usesI2c: uses('i2c_'),
242
- usesSpi: uses('spi_'),
243
- usesUart: uses('uart_'),
696
+ // __tc_<bus> matches the shim state block — a begin()-only program
697
+ // emits no driver API call but still declares the DT device. A
698
+ // constructed sensor is also a bus user (its device handle is the
699
+ // only i2c reference a sensor-only program carries).
700
+ usesI2c: uses('i2c_') || uses('__tc_i2c') || sensorParts.length > 0,
701
+ usesSensor: uses('sensor_') || sensorParts.length > 0,
702
+ usesFloatFormat: /%[-0-9.]*[eEfFgG]/.test(src),
703
+ sensorParts,
704
+ spiTargets: spiTargetParts,
705
+ usesSpi: uses('spi_') || uses('__tc_spi') || spiTargetParts.length > 0,
706
+ usesUart: uses('__tc_uart'),
707
+ usesUsb: uses('__tc_usb'),
708
+ usesPwm: uses('pwm_'),
709
+ usesAdc: uses('adc_'),
710
+ adcReadPins: scanAdcReadPins(src, chip),
711
+ dacWritePins: scanDacWritePins(src),
712
+ pwmUsedPins: scanPwmUsedPins(src, chip),
713
+ i2cUsedInstances: scanUsedBusInstances(src, chip.i2c?.controllers, 'i2c'),
714
+ spiUsedInstances: scanUsedBusInstances(src, chip.spi?.controllers, 'spi'),
715
+ uartUsedInstances: scanUsedBusInstances(src, chip.uart?.controllers, 'uart'),
716
+ // Preferences/FS — same tokens scaffoldZephyrProject scans (the ZMS
717
+ // settings_* API + __tc_prefs shim, the __tc_fs mount shim); drive the
718
+ // storage-partition synthesis + /chosen settings pointer.
719
+ usesPreferences: uses('settings_') || uses('__tc_prefs'),
720
+ usesFS: uses('__tc_fs'),
721
+ usesWdt: uses('wdt_'),
722
+ usesHwtimer: uses('counter_') || uses('__tc_hw'),
244
723
  usesDisplay,
245
724
  usesTouch: usesTouch || usesXpt,
246
725
  touchController: usesXpt ? 'xpt2046' : 'ft6336u',
247
726
  }, displayProfile);
248
727
  const overlayDir = join(projectRoot, 'boards');
249
728
  mkdirSync(overlayDir, { recursive: true });
250
- writeIfChanged(join(overlayDir, `${board}.overlay`), overlay);
729
+ // prepare() runs before the real target is known — the placeholder name
730
+ // never matches `west build -b <board>` (compile rewrites the overlay
731
+ // under the actual board's name below).
732
+ writeIfChanged(join(overlayDir, 'board.overlay'), overlay);
733
+ // Thin SPI targets need an app-local binding: a compatible-less DT node
734
+ // generates NO property macros, so SPI_DT_SPEC_GET's spi-max-frequency
735
+ // lookup would not exist. The binding has no driver — it exists so
736
+ // gen_defines emits the spi properties for the target nodes.
737
+ if (spiTargetParts.length > 0) {
738
+ const bindingsDir = join(projectRoot, 'dts', 'bindings');
739
+ mkdirSync(bindingsDir, { recursive: true });
740
+ writeIfChanged(join(bindingsDir, 'cuttlefish,spi-target.yaml'), [
741
+ 'description: |',
742
+ ' Cuttlefish thin SPITarget (hal/spi-target.ts) — a raw spi_dt_spec',
743
+ ' peer. No driver binds this compatible; it exists so devicetree',
744
+ ' generation emits the spi properties (spi-max-frequency,',
745
+ ' spi-cpol/spi-cpha, reg = the cs-gpios index) that SPI_DT_SPEC_GET',
746
+ ' consumes from the generated C++.',
747
+ 'compatible: "cuttlefish,spi-target"',
748
+ 'include: spi-device.yaml',
749
+ '',
750
+ ].join('\n'));
751
+ }
251
752
  },
252
753
 
253
754
  compile(o: ToolchainOptions): CompileResult {
@@ -286,7 +787,22 @@ export const Toolchain = {
286
787
  // the <default>.overlay it wrote does not match `west build -b <board>`.
287
788
  // Zephyr auto-detects boards/<board>.overlay under APPLICATION_CONFIG_DIR.
288
789
  try {
289
- const chip = chipForTarget(board);
790
+ const chip = chipForBuild(projectRoot, board);
791
+ // Custom-board generation: an MCU-only target (no board package) has no
792
+ // upstream Zephyr board — generate one under boards/typecad/<name>/ from
793
+ // the chip's silicon data. Opt-in via `zephyr.customBoard: true` in
794
+ // cuttlefish.config.ts; the board takes its name from the build target.
795
+ // Idempotent — regenerated on every compile, before the overlay pass.
796
+ if (zc?.customBoard === true) {
797
+ const generated = generateCustomBoard(projectRoot, chip, board.split('/')[0]);
798
+ if (!generated) {
799
+ throw new Error(
800
+ `zephyr.customBoard is set, but the resolved chip ('${chip.id}') carries no ` +
801
+ `silicon board data. Custom-board generation requires an MCU-only config ` +
802
+ `(mcu set, board absent) whose MCU package ships a zephyr block.`,
803
+ );
804
+ }
805
+ }
290
806
  const srcDir = join(projectRoot, 'src');
291
807
  let src = '';
292
808
  try {
@@ -373,10 +889,35 @@ export const Toolchain = {
373
889
  touchWiring = { controller: 'xpt2046', ...(touchWiring ?? {}) };
374
890
  }
375
891
  const overlayDiagnostics: OverlayDiagnostic[] = [];
892
+ const sensorParts = scanSensorParts(src);
893
+ const spiTargetParts = scanSpiTargets(src);
376
894
  const overlay = generateOverlay(chip, {
377
- usesI2c: uses('i2c_'),
378
- usesSpi: uses('spi_'),
379
- usesUart: uses('uart_'),
895
+ // __tc_<bus> matches the shim state block — a begin()-only program
896
+ // emits no driver API call but still declares the DT device. A
897
+ // constructed sensor is also a bus user (see scanSensorParts).
898
+ usesI2c: uses('i2c_') || uses('__tc_i2c') || sensorParts.length > 0,
899
+ usesSensor: uses('sensor_') || sensorParts.length > 0,
900
+ usesFloatFormat: /%[-0-9.]*[eEfFgG]/.test(src),
901
+ sensorParts,
902
+ spiTargets: spiTargetParts,
903
+ usesSpi: uses('spi_') || uses('__tc_spi') || spiTargetParts.length > 0,
904
+ usesUart: uses('__tc_uart'),
905
+ usesUsb: uses('__tc_usb'),
906
+ usesPwm: uses('pwm_'),
907
+ usesAdc: uses('adc_'),
908
+ adcReadPins: scanAdcReadPins(src, chip),
909
+ usesDac: uses('dac_') || uses('__tc_dac'),
910
+ dacWritePins: scanDacWritePins(src),
911
+ pwmUsedPins: scanPwmUsedPins(src, chip),
912
+ i2cUsedInstances: scanUsedBusInstances(src, chip.i2c?.controllers, 'i2c'),
913
+ spiUsedInstances: scanUsedBusInstances(src, chip.spi?.controllers, 'spi'),
914
+ uartUsedInstances: scanUsedBusInstances(src, chip.uart?.controllers, 'uart'),
915
+ // Preferences/FS — same tokens scaffoldZephyrProject scans; drive the
916
+ // storage-partition synthesis + /chosen settings pointer.
917
+ usesPreferences: uses('settings_') || uses('__tc_prefs'),
918
+ usesWdt: uses('wdt_'),
919
+ usesHwtimer: uses('counter_') || uses('__tc_hw'),
920
+ usesFS: uses('__tc_fs'),
380
921
  usesDisplay,
381
922
  usesTouch: uses('ft6336u') || uses('touch_') || usesXpt,
382
923
  touchController: usesXpt ? 'xpt2046' : 'ft6336u',
@@ -390,9 +931,31 @@ export const Toolchain = {
390
931
  // Write the board-specific overlay (the one west loads). Zephyr looks for
391
932
  // boards/<board_id>.overlay under APPLICATION_CONFIG_DIR — use the bare
392
933
  // board id (before any hardware-qualifier suffix, e.g. 'esp32_devkitc'
393
- // not the full 'esp32_devkitc/esp32/procpu' target string).
934
+ // not the full 'esp32_devkitc/esp32/procpu' target string). Library
935
+ // packages' overlay fragments are appended by the scaffold helper.
394
936
  const boardId = board.split('/')[0];
395
- writeIfChanged(join(overlayDir, `${boardId}.overlay`), overlay);
937
+ writeIfChanged(
938
+ join(overlayDir, `${boardId}.overlay`),
939
+ appendLibraryOverlayFragments(overlay, projectRoot),
940
+ );
941
+ // Thin SPI targets need the app-local binding (see the transpile-side
942
+ // write for the rationale): no compatible → no generated spi props →
943
+ // SPI_DT_SPEC_GET's spi-max-frequency lookup does not exist.
944
+ if (spiTargetParts.length > 0) {
945
+ const bindingsDir = join(projectRoot, 'dts', 'bindings');
946
+ mkdirSync(bindingsDir, { recursive: true });
947
+ writeIfChanged(join(bindingsDir, 'cuttlefish,spi-target.yaml'), [
948
+ 'description: |',
949
+ ' Cuttlefish thin SPITarget (hal/spi-target.ts) — a raw spi_dt_spec',
950
+ ' peer. No driver binds this compatible; it exists so devicetree',
951
+ ' generation emits the spi properties (spi-max-frequency,',
952
+ ' spi-cpol/spi-cpha, reg = the cs-gpios index) that SPI_DT_SPEC_GET',
953
+ ' consumes from the generated C++.',
954
+ 'compatible: "cuttlefish,spi-target"',
955
+ 'include: spi-device.yaml',
956
+ '',
957
+ ].join('\n'));
958
+ }
396
959
  } catch { /* best-effort overlay regen; the build surfaces DT errors */ }
397
960
 
398
961
  // Use a stable build dir so incremental builds reuse the Ninja graph.
@@ -472,11 +1035,11 @@ export const Toolchain = {
472
1035
  // toolchain compile() debug-config wiring.
473
1036
  if (result.status === 0 && isGdbDebug) {
474
1037
  try {
475
- const { workspaceRoot, sketchRel } = resolveDebugLocations(projectRoot);
1038
+ const { workspaceRoot, appRel } = resolveDebugLocations(projectRoot);
476
1039
  writeDebugConfig({
477
1040
  projectRoot,
478
1041
  workspaceRoot,
479
- sketchRel,
1042
+ appRel,
480
1043
  target: board,
481
1044
  buildDir,
482
1045
  sourceMapPath: join(dirname(o.sourcePath), `${basename(o.sourcePath)}.thcppmap.json`),
@@ -486,6 +1049,40 @@ export const Toolchain = {
486
1049
  }
487
1050
  }
488
1051
 
1052
+ // As-built snapshot: after a successful build, the resolved devicetree
1053
+ // at <buildDir>/zephyr/zephyr.dts carries the board's pinctrl labels —
1054
+ // the STABLE name grammar, immune to vendor macro churn. Harvest its
1055
+ // routes into .cuttlefish/as-built.json; the next build's board-module
1056
+ // generation merges them per-pin over the catalog harvest (build wins,
1057
+ // silently when they agree). One-build freshness lag on first setup,
1058
+ // self-maintaining after. Best-effort — a missing/unparseable artifact
1059
+ // never fails the build.
1060
+ if (result.status === 0) {
1061
+ try {
1062
+ const dtsPath = join(buildDir, 'zephyr', 'zephyr.dts');
1063
+ const dtsText = readFileSync(dtsPath, 'utf8');
1064
+ const facts = parseZephyrDts(dtsText);
1065
+ const total = facts.adc.length + facts.pwm.length + facts.dac.length;
1066
+ if (total > 0) {
1067
+ // Write beside the project's board module — the .cuttlefish dir the
1068
+ // config loader reads from, discovered by walking up to the
1069
+ // generated board.json (the scaffold root and the config root are
1070
+ // different dirs in the standard layout: src/out vs project root).
1071
+ let cfDir = join(projectRoot, '.cuttlefish');
1072
+ for (let dir = projectRoot; ; dir = dirname(dir)) {
1073
+ if (existsSync(join(dir, '.cuttlefish', 'board.json'))) {
1074
+ cfDir = join(dir, '.cuttlefish');
1075
+ break;
1076
+ }
1077
+ const parent = dirname(dir);
1078
+ if (parent === dir) break;
1079
+ }
1080
+ mkdirSync(cfDir, { recursive: true });
1081
+ writeIfChanged(join(cfDir, 'as-built.json'), asBuiltJson(board, facts));
1082
+ }
1083
+ } catch { /* best-effort snapshot — nothing to harvest or unreadable */ }
1084
+ }
1085
+
489
1086
  return {
490
1087
  success: result.status === 0,
491
1088
  output: header + output,
@@ -498,22 +1095,135 @@ export const Toolchain = {
498
1095
  const buildDir = join(projectRoot, 'build');
499
1096
  const board = targetFromOptions(o);
500
1097
  const zc = o.zephyrConfig as Record<string, unknown> | undefined;
501
- const runner = zc?.runner as string | undefined;
502
- const args = buildFlashArgs(buildDir, board, runner, o.port);
1098
+ const chip = chipForBuild(projectRoot, board);
1099
+ const probe = resolveProbeMethod(zc, chip, 'flash');
1100
+ if (!probe.ok) {
1101
+ return { success: false, output: `-- west flash: ${probe.error}` };
1102
+ }
1103
+ // BOSSA bootloader boards with touch-reset data: open the app's console
1104
+ // port at 1200 baud (the firmware's USB shim reboots into the
1105
+ // bootloader), wait for the bootloader identity, and flash THAT port.
1106
+ // Falls back to the configured port (manual double-tap) on any failure.
1107
+ let flashPort = o.port;
1108
+ const flashNotes: string[] = [];
1109
+ // The runner this flash will actually use: the explicit choice, else the
1110
+ // board's declared default (first probe-method entry). Gates port
1111
+ // forwarding and the bossac touch below — board.cmake still resolves
1112
+ // the default runner itself.
1113
+ const flashRunner = probe.runner ?? chip.probeMethods?.[0]?.runner;
1114
+ // Serial-port runners cannot flash without a port; every other runner
1115
+ // (probe or USB) proceeds — whether a port is required is the runner's
1116
+ // call, not the CLI's blanket gate.
1117
+ if (uploadRequiresPort(flashRunner) && !flashPort) {
1118
+ return {
1119
+ success: false,
1120
+ output: `-- upload requires a port for ${flashRunner} flashing. Set --port <port> on the command line (or the CUTTLEFISH_PORT env var).`,
1121
+ };
1122
+ }
1123
+ if (flashRunner === 'bossac' && flashPort && chip.usb?.touchReset) {
1124
+ const touch = bossacTouchReset(flashPort, chip.usb.touchReset);
1125
+ flashNotes.push(`-- ${touch.note}`);
1126
+ if (touch.port) flashPort = touch.port;
1127
+ }
1128
+ const args = buildFlashArgs(buildDir, probe.runner, flashPort, flashRunner, probe.args);
503
1129
 
504
- const inv = westSpawn(args, {
505
- cwd: projectRoot,
506
- encoding: 'utf-8',
507
- timeout: FLASH_TIMEOUT_MS,
508
- });
509
- const result = spawnSync(inv.command, inv.args, inv.options);
1130
+ // openocd flashes go through a dedicated session instead of `west flash`.
1131
+ // west's flow has three sequential races that each strand the board: the
1132
+ // connect happens against a running (often USB-active) application, the
1133
+ // erase precedes the write so the vector table is 0xFFFFFFFF while the
1134
+ // RAM algorithm runs (any exception → core LOCKUP at 0xFFFFFFFE, "timeout
1135
+ // waiting for algorithm"), and the trailing `reset run` frequently does
1136
+ // not reach the core. This session is deterministic end to end: halt at
1137
+ // the reset vector (static target for the DAP), mask interrupts for the
1138
+ // algorithm (exceptions cannot vector through erased flash), unmask
1139
+ // after, and boot the flashed app with a direct SYSRESETREQ. Falls back
1140
+ // to `west flash` when the image or session is unavailable.
1141
+ let westFallback = true;
1142
+ if (probe.runner === 'openocd') {
1143
+ const hex = join(buildDir, 'zephyr', 'zephyr.hex');
1144
+ if (existsSync(hex)) {
1145
+ // Forward slashes + TCL quoting so project paths with spaces work.
1146
+ const hexArg = `"${hex.replace(/\\/g, '/')}"`;
1147
+ // Target addressing: `cortex_m` is a PER-TARGET subcommand — a bare
1148
+ // `cortex_m maskisr on` is an unknown command (a silent no-op inside
1149
+ // catch). Resolve the session's target object once and address it.
1150
+ // Cortex-M-only, self-gating: on other cores the cortex_m method
1151
+ // errors and catch contains it (the plain flash path is safe there
1152
+ // without masking — the lockup class is Cortex-M vectoring).
1153
+ const flashed = openocdProbeSession(buildDir, zc, chip, [
1154
+ 'set _tgt [lindex [target names] 0]',
1155
+ 'reset halt',
1156
+ 'catch { $_tgt cortex_m maskisr on }',
1157
+ `flash write_image erase ${hexArg}`,
1158
+ 'catch { $_tgt cortex_m maskisr off }',
1159
+ // Boot the flashed app. Cortex-M: a direct SYSRESETREQ via AIRCR —
1160
+ // pin-independent, always reaches the core, and clears PRIMASK
1161
+ // (so the masked algorithm leaves nothing behind). Other cores:
1162
+ // openocd's generic reset run. A halted core STAYS halted across
1163
+ // a core-initiated reset (debug halt state survives — that is how
1164
+ // reset halt works), so resume it; on a running core resume errors
1165
+ // and the catch swallows it.
1166
+ 'if {[catch {$_tgt cortex_m maskisr on}] == 0} { $_tgt cortex_m maskisr off; mww 0xE000ED0C 0x05FA0004 } else { reset run }',
1167
+ 'sleep 100',
1168
+ 'catch { resume }',
1169
+ 'sleep 200',
1170
+ ]);
1171
+ if (flashed) {
1172
+ westFallback = false;
1173
+ flashNotes.push('-- probe flash ok: halt → masked write → SYSRESETREQ');
1174
+ }
1175
+ }
1176
+ }
510
1177
 
511
- const fstdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
512
- const fstderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
513
- const raw = fstdout + fstderr;
1178
+ let result: ReturnType<typeof spawnSync> | undefined;
1179
+ let raw = '';
1180
+ let ok = false;
1181
+ if (westFallback) {
1182
+ const inv = westSpawn(args, {
1183
+ cwd: projectRoot,
1184
+ encoding: 'utf-8',
1185
+ timeout: FLASH_TIMEOUT_MS,
1186
+ });
1187
+ result = spawnSync(inv.command, inv.args, inv.options);
1188
+ const fstdout = typeof result.stdout === 'string' ? result.stdout : (result.stdout?.toString() ?? '');
1189
+ const fstderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? '');
1190
+ raw = fstdout + fstderr;
1191
+ ok = classifyUploadResult(probe.runner, result.status, raw);
1192
+ // A SUCCESSFUL west openocd flash can still leave the core HALTED:
1193
+ // west's trailing `reset run` does not reach a core behind an unwired
1194
+ // SRST, and the user's only recourse is the NRST button. Boot it from
1195
+ // a probe session instead — one core reset (a halted core re-halts at
1196
+ // the reset vector; a running core restarts the just-flashed app)
1197
+ // plus a resume.
1198
+ if (ok && probe.runner === 'openocd') {
1199
+ const booted = openocdProbeSession(buildDir, zc, chip, [
1200
+ 'set _tgt [lindex [target names] 0]',
1201
+ 'if {[catch {$_tgt cortex_m maskisr on}] == 0} { $_tgt cortex_m maskisr off; mww 0xE000ED0C 0x05FA0004 } else { reset run }',
1202
+ 'sleep 100',
1203
+ 'catch { resume }',
1204
+ 'sleep 200',
1205
+ ]);
1206
+ if (booted) flashNotes.push('-- probe boot ok: SYSRESETREQ → resume');
1207
+ }
1208
+ // Fallback-path recovery: a failed west flash leaves the core in
1209
+ // lockup; a direct SYSRESETREQ clears it so the caller's retry (or a
1210
+ // later flash) starts from a clean chip.
1211
+ if (!ok && probe.runner === 'openocd') {
1212
+ const revived = openocdProbeSession(buildDir, zc, chip, [
1213
+ 'init',
1214
+ 'set _tgt [lindex [target names] 0]',
1215
+ 'if {[catch {$_tgt cortex_m maskisr on}] == 0} { $_tgt cortex_m maskisr off; mww 0xE000ED0C 0x05FA0004 } else { reset run }',
1216
+ 'sleep 300',
1217
+ ]);
1218
+ if (revived) flashNotes.push(revived);
1219
+ }
1220
+ } else {
1221
+ ok = true;
1222
+ }
514
1223
  return {
515
- success: classifyUploadResult(runner, result.status, raw),
516
- output: cleanseUploadOutput(runner, result.status, raw),
1224
+ success: ok,
1225
+ output: [...flashNotes, cleanseUploadOutput(probe.runner, westFallback ? result!.status : 0, raw)]
1226
+ .filter(Boolean).join('\n'),
517
1227
  };
518
1228
  },
519
1229
 
@@ -531,7 +1241,7 @@ export const Toolchain = {
531
1241
  }
532
1242
  // ESP32 USB-CDC console runs at 115200 (the Zephyr ESP32 board default).
533
1243
  // The CLI's generic default of 9600 is wrong for this target; honor an
534
- // explicit --baud / config.console.baudRate when given, else 115200.
1244
+ // explicit --baud when given, else 115200.
535
1245
  const baud = o.baud ?? 115200;
536
1246
  const install = discoverWest();
537
1247
  const py = install?.pythonExecutable ?? process.env.PYTHON ?? 'python';
@@ -547,15 +1257,26 @@ export const Toolchain = {
547
1257
  },
548
1258
 
549
1259
  debug(o: ToolchainOptions): void {
550
- // Launch an interactive GDB session for the last build. `west debug`
551
- // auto-resolves the runner (openocd for esp32s3, nrfjprog/jlink for nRF)
552
- // and the GDB binary from the build dir's CMakeCache/board.cmake no
1260
+ // Launch an interactive GDB session for the last build. The probe method
1261
+ // resolves exactly like flashing (zephyr.probe / zephyr.runner the same
1262
+ // attach session, so the same quirks apply); debug-incapable methods
1263
+ // (bootloaders) are rejected with the debug-capable list. west debug
1264
+ // resolves the GDB binary from the build dir's CMakeCache — no
553
1265
  // hand-authored gdbinit needed. Inherits stdio so GDB runs interactively.
554
- // (Not invoked by the standard build/compile flow; powers an explicit
555
- // debug-attach entry point for terminal-driven debugging without VS Code.)
556
1266
  const projectRoot = projectRootFromOptions(o);
557
1267
  const buildDir = join(projectRoot, 'build');
558
- const inv = westSpawn(['debug', '-d', buildDir], {
1268
+ const board = targetFromOptions(o);
1269
+ const zc = o.zephyrConfig as Record<string, unknown> | undefined;
1270
+ const probe = resolveProbeMethod(zc, chipForBuild(projectRoot, board), 'debug');
1271
+ if (!probe.ok) {
1272
+ console.error(`-- west debug: ${probe.error}`);
1273
+ process.exitCode = 1;
1274
+ return;
1275
+ }
1276
+ const debugArgs = ['debug', '-d', buildDir];
1277
+ if (probe.runner) debugArgs.push('--runner', probe.runner);
1278
+ debugArgs.push(...probe.args);
1279
+ const inv = westSpawn(debugArgs, {
559
1280
  cwd: projectRoot,
560
1281
  encoding: 'utf-8',
561
1282
  stdio: 'inherit',