@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
package/dist/strategy.js CHANGED
@@ -1,9 +1,12 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // ZephyrStrategy — Zephyr RTOS target
3
3
  //
4
- // Outputs C++ built with `west` (the Zephyr build tool). Programs use the
5
- // Arduino-style setup()/loop() pair; a generated main() bridges them into
6
- // Zephyr's standard C entry point and yields to the scheduler between loops.
4
+ // Outputs C++ built with `west` (the Zephyr build tool). Zephyr is a standard
5
+ // C main()-based RTOS, so the program's entrypoint IS main(): top-level
6
+ // statements lower straight into it. Worker programs keep the scaffolded
7
+ // top-level `while (true)`; event-driven programs (UI / async / native
8
+ // timers) end with a scheduler loop in main() that ticks the UI runtime and
9
+ // pumps microtasks each frame, yielding to the kernel with k_msleep(1).
7
10
  //
8
11
  // GPIO is lowered through devicetree specs (gpio_pin_*_dt) so an active-low
9
12
  // LED's polarity is honored by the DT flags, not by the generated code. See
@@ -14,24 +17,367 @@
14
17
  // are covered by the TypeCAD Runtime Exception (see RUNTIME_EXCEPTION.md at the
15
18
  // repository root) and are not subject to the license of this tool source.
16
19
  // ---------------------------------------------------------------------------
17
- import { buildWorkerRuntimePolyfill } from '@typecad/cuttlefish/api/shared';
18
20
  import { applyStringMethodRewrites } from '@typecad/cuttlefish/api/shared';
19
21
  import { programUsesSafety } from '@typecad/cuttlefish/api';
20
22
  import { entryHasUI } from '@typecad/cuttlefish/ui-hook';
21
- import { chipForTarget, setActiveChip } from './chips/index.js';
23
+ import { generateBoard, generateBoardModuleFromContract } from './boardgen.js';
24
+ import { syncBoardCatalog, ensureFreshBoardCatalog, } from './sdk/board-catalog-sync.js';
25
+ import { setActiveChip, NO_BOARD_CHIP } from './chips/index.js';
22
26
  import { resolveChipFromBoard } from './chips/resolve.js';
23
27
  import { emitGpioDevDispatcher } from './chips/controllers.js';
28
+ /**
29
+ * Deep-walk the program IR and collect the HAL pin numbers the program
30
+ * actually touches for a peripheral family ('adc' | 'pwm') — the same walk
31
+ * profileDiagnostics does. Emit paths gate per-channel state on these sets
32
+ * so nothing unused reaches the single generated TU (-Wunused-function
33
+ * hygiene: every emitted function/variable is referenced). `undefined`
34
+ * (no program — probe paths) means "no information": callers emit every
35
+ * descriptor channel, preserving probe behavior.
36
+ */
37
+ function collectUsedPins(program, kind, chip) {
38
+ if (!program)
39
+ return undefined;
40
+ const pins = new Set();
41
+ const visit = (node) => {
42
+ if (!node || typeof node !== 'object')
43
+ return;
44
+ const n = node;
45
+ const op = n.operation;
46
+ if (op && typeof op === 'object') {
47
+ const o = op;
48
+ const name = o.operation;
49
+ const pin = o.pin;
50
+ if (typeof name === 'string' && typeof pin === 'number') {
51
+ if (kind === 'adc' && (name === 'adc.read' || name === 'adc.read_voltage' || name === 'adc.read_raw' || name === 'adc.read_mv'))
52
+ pins.add(pin);
53
+ if (kind === 'pwm' && name.startsWith('pwm.'))
54
+ pins.add(pin);
55
+ }
56
+ }
57
+ for (const v of Object.values(n)) {
58
+ if (Array.isArray(v)) {
59
+ for (const item of v)
60
+ visit(item);
61
+ }
62
+ else if (v && typeof v === 'object')
63
+ visit(v);
64
+ }
65
+ };
66
+ visit(program);
67
+ return pins;
68
+ }
69
+ /**
70
+ * The inline-override escape hatch: ADC ops carrying construction-time
71
+ * device overrides (hal/adc-pin.ts opts) name DT device labels the manifest
72
+ * may not know — the shim's init block must declare their device handles
73
+ * (the reads reference them) and fire even when the chip has no adc facts
74
+ * at all.
75
+ */
76
+ function collectAdcOverrideDevices(program) {
77
+ const devices = new Set();
78
+ if (!program)
79
+ return devices;
80
+ const visit = (node) => {
81
+ if (!node || typeof node !== 'object')
82
+ return;
83
+ const n = node;
84
+ const op = n.operation;
85
+ if (op && typeof op === 'object') {
86
+ const o = op;
87
+ if (typeof o.operation === 'string' && o.operation.startsWith('adc.')
88
+ && typeof o.deviceOverride === 'string' && o.deviceOverride !== '') {
89
+ devices.add(o.deviceOverride);
90
+ }
91
+ }
92
+ for (const v of Object.values(n)) {
93
+ if (Array.isArray(v)) {
94
+ for (const item of v)
95
+ visit(item);
96
+ }
97
+ else if (v && typeof v === 'object')
98
+ visit(v);
99
+ }
100
+ };
101
+ visit(program);
102
+ return devices;
103
+ }
104
+ /**
105
+ * PWM construction-time overrides (the escape hatch): controller+channel
106
+ * vouched for by the user on pins the manifest may not map. The shim emits
107
+ * their alias vars; the overlay regen's marker merge creates the DT nodes.
108
+ */
109
+ function collectPwmOverrideSpecs(program) {
110
+ const specs = [];
111
+ if (!program)
112
+ return specs;
113
+ const seen = new Set();
114
+ const visit = (node) => {
115
+ if (!node || typeof node !== 'object')
116
+ return;
117
+ const n = node;
118
+ const op = n.operation;
119
+ if (op && typeof op === 'object') {
120
+ const o = op;
121
+ const ctrl = typeof o.controllerOverride === 'string' && o.controllerOverride !== '' ? o.controllerOverride : undefined;
122
+ const ch = typeof o.channelOverride === 'number' && o.channelOverride >= 0 ? o.channelOverride : undefined;
123
+ if (typeof o.operation === 'string' && o.operation.startsWith('pwm.')
124
+ && typeof o.pin === 'number' && (ctrl || ch !== undefined) && !seen.has(o.pin)) {
125
+ seen.add(o.pin);
126
+ specs.push({ pin: o.pin, controller: ctrl ?? 'pwm0', channel: ch ?? 0 });
127
+ }
128
+ }
129
+ for (const v of Object.values(n)) {
130
+ if (Array.isArray(v)) {
131
+ for (const item of v)
132
+ visit(item);
133
+ }
134
+ else if (v && typeof v === 'object')
135
+ visit(v);
136
+ }
137
+ };
138
+ visit(program);
139
+ return specs;
140
+ }
141
+ /**
142
+ * Which I2C/SPI/UART controller indexes the program actually drives (parsed
143
+ * from the ops' bus/port id, same as the lowerings' parseControllerIndex).
144
+ * `undefined` (no program — probe paths) means "no information": callers
145
+ * emit every declared controller, preserving probe behavior.
146
+ *
147
+ * The per-controller state blocks are emitted only for these indexes — an
148
+ * unused declared controller's `__tc_<bus>N_dev` would trip -Werror's
149
+ * -Wunused-variable (Zephyr builds with warnings-as-errors), the same
150
+ * hygiene collectUsedPins enforces for per-channel ADC/PWM state.
151
+ */
152
+ function collectUsedBusIndices(program) {
153
+ if (!program)
154
+ return undefined;
155
+ const indices = { i2c: new Set(), spi: new Set(), uart: new Set() };
156
+ const visit = (node) => {
157
+ if (!node || typeof node !== 'object')
158
+ return;
159
+ const n = node;
160
+ const op = n.operation;
161
+ if (op && typeof op === 'object') {
162
+ const o = op;
163
+ const name = o.operation;
164
+ if (typeof name === 'string') {
165
+ const m = name.match(/^(i2c|spi|uart)\./);
166
+ if (m) {
167
+ const bus = m[1];
168
+ const id = typeof o.bus === 'string' ? o.bus : typeof o.port === 'string' ? o.port : undefined;
169
+ // parseControllerIndex() (what the lowering uses to pick the state
170
+ // prefix) maps a digitless alias like 'Wire'/'SPI'/'Serial' to
171
+ // controller 0 — the collector must agree, or the state block for
172
+ // controller 0 is skipped and every __tc_<bus>0_* reference dangles.
173
+ const idx = id?.match(/(\d+)$/);
174
+ indices[bus].add(idx ? parseInt(idx[1], 10) : 0);
175
+ }
176
+ }
177
+ }
178
+ for (const v of Object.values(n)) {
179
+ if (Array.isArray(v)) {
180
+ for (const item of v)
181
+ visit(item);
182
+ }
183
+ else if (v && typeof v === 'object')
184
+ visit(v);
185
+ }
186
+ };
187
+ visit(program);
188
+ return indices;
189
+ }
190
+ /**
191
+ * Collect the distinct DT-bound sensors the program's ops reference, as
192
+ * `${part}|${bus}|${address}` keys (same shape as peripheral-usage's
193
+ * sensorPartsUsed). Feeds the per-sensor device-handle state block — only
194
+ * constructed sensors emit state, and only sensors whose ops were emitted
195
+ * get a DT node.
196
+ */
197
+ export function collectSensors(program) {
198
+ if (!program)
199
+ return undefined;
200
+ const sensors = new Map();
201
+ const visit = (node) => {
202
+ if (!node || typeof node !== 'object')
203
+ return;
204
+ const n = node;
205
+ const op = n.operation;
206
+ if (op && typeof op === 'object') {
207
+ const o = op;
208
+ if (o.operation === 'sensor.fetch' || o.operation === 'sensor.get') {
209
+ const part = String(o.part ?? '');
210
+ const bus = String(o.bus ?? '');
211
+ const port = o.port ?? 0;
212
+ const busKind = String(o.busKind ?? 'i2c');
213
+ const spiHz = o.spiHz ?? 0;
214
+ const spiMode = o.spiMode ?? 0;
215
+ const alertPin = o.alertPin ?? -1;
216
+ const key = `${part}|${bus}|${port}|${busKind}`;
217
+ if (!sensors.has(key))
218
+ sensors.set(key, { part, bus, port, busKind, spiHz, spiMode, alertPin });
219
+ }
220
+ }
221
+ for (const v of Object.values(n)) {
222
+ if (Array.isArray(v)) {
223
+ for (const item of v)
224
+ visit(item);
225
+ }
226
+ else if (v && typeof v === 'object')
227
+ visit(v);
228
+ }
229
+ };
230
+ visit(program);
231
+ return sensors;
232
+ }
233
+ /**
234
+ * Collect the distinct thin SPI targets the program's ops reference
235
+ * (spi.transceive / spi.dev_write), keyed `${bus}|${cs}`. Mirrors
236
+ * collectSensors: only constructed targets whose ops were emitted get a
237
+ * spi_dt_spec state block and a DT child node.
238
+ */
239
+ export function collectSpiTargets(program) {
240
+ if (!program)
241
+ return undefined;
242
+ const targets = new Map();
243
+ const visit = (node) => {
244
+ if (!node || typeof node !== 'object')
245
+ return;
246
+ const n = node;
247
+ const op = n.operation;
248
+ if (op && typeof op === 'object') {
249
+ const o = op;
250
+ if (o.operation === 'spi.transceive' || o.operation === 'spi.dev_write' || o.operation === 'spi.reg_read') {
251
+ const bus = String(o.bus ?? '');
252
+ const cs = o.cs ?? 0;
253
+ const key = `${bus}|${cs}`;
254
+ if (!targets.has(key)) {
255
+ targets.set(key, { bus, cs, hz: o.hz ?? 0, mode: o.mode ?? 0 });
256
+ }
257
+ }
258
+ }
259
+ for (const v of Object.values(n)) {
260
+ if (Array.isArray(v)) {
261
+ for (const item of v)
262
+ visit(item);
263
+ }
264
+ else if (v && typeof v === 'object')
265
+ visit(v);
266
+ }
267
+ };
268
+ visit(program);
269
+ return targets;
270
+ }
271
+ /**
272
+ * Collect the distinct UART RX rings the program reads (uart.rx_* ops),
273
+ * keyed by controller index. Mirrors collectSpiTargets: only ports whose RX
274
+ * ops were emitted get a ring + ISR (an unused ISR is -Wunused-function
275
+ * under Zephyr's -Werror).
276
+ */
277
+ export function collectUartRings(program) {
278
+ if (!program)
279
+ return undefined;
280
+ const rings = new Map();
281
+ const visit = (node) => {
282
+ if (!node || typeof node !== 'object')
283
+ return;
284
+ const n = node;
285
+ const op = n.operation;
286
+ if (op && typeof op === 'object') {
287
+ const o = op;
288
+ if (o.operation === 'uart.rx_arm' || o.operation === 'uart.rx_available'
289
+ || o.operation === 'uart.rx_peek' || o.operation === 'uart.rx_read') {
290
+ const port = String(o.port ?? 'UART0');
291
+ const m = port.match(/(UART|uart)(\d+)/) ?? port.match(/(\d+)/);
292
+ const index = m ? parseInt(m[m.length - 1], 10) : 0;
293
+ if (!rings.has(index)) {
294
+ rings.set(index, { index, ring: o.ring ?? 64 });
295
+ }
296
+ }
297
+ }
298
+ for (const v of Object.values(n)) {
299
+ if (Array.isArray(v)) {
300
+ for (const item of v)
301
+ visit(item);
302
+ }
303
+ else if (v && typeof v === 'object')
304
+ visit(v);
305
+ }
306
+ };
307
+ visit(program);
308
+ return rings;
309
+ }
310
+ /**
311
+ * Collect the distinct threads the program STARTS (thread.start ops), keyed
312
+ * by instance. Only started threads emit state — see shimLines.
313
+ */
314
+ export function collectThreads(program) {
315
+ if (!program)
316
+ return undefined;
317
+ const threads = new Map();
318
+ const visit = (node) => {
319
+ if (!node || typeof node !== 'object')
320
+ return;
321
+ const n = node;
322
+ const op = n.operation;
323
+ if (op && typeof op === 'object') {
324
+ const o = op;
325
+ if (o.operation === 'thread.start') {
326
+ const instance = o.instance ?? 0;
327
+ if (!threads.has(instance)) {
328
+ threads.set(instance, { instance, stackBytes: o.stackBytes ?? 2048, priority: o.priority ?? 5 });
329
+ }
330
+ }
331
+ }
332
+ for (const v of Object.values(n)) {
333
+ if (Array.isArray(v)) {
334
+ for (const item of v)
335
+ visit(item);
336
+ }
337
+ else if (v && typeof v === 'object')
338
+ visit(v);
339
+ }
340
+ };
341
+ visit(program);
342
+ return threads;
343
+ }
24
344
  import { lowerHalOp } from './lowering/index.js';
25
- import { buildZephyrWorkerBacking } from './lowering/worker-backing.js';
26
- import { adcInitLines } from './lowering/adc.js';
345
+ import { adcInitLines, adcChannelForPin } from './lowering/adc.js';
27
346
  import { pwmInitLines } from './lowering/pwm.js';
28
347
  import { dacInitLines } from './lowering/dac.js';
29
348
  import { fsInitLines } from './lowering/fs.js';
30
349
  import { hwtimerInitLines } from './lowering/hwtimer.js';
31
350
  import { i2cInitLines } from './lowering/i2c.js';
351
+ import { sensorStateLines } from './lowering/sensor.js';
352
+ import { spiTargetStateLines } from './lowering/spi.js';
353
+ import { threadStateLines } from './lowering/thread.js';
354
+ import { uartRingStateLines } from './lowering/uart.js';
32
355
  import { spiInitLines } from './lowering/spi.js';
33
356
  import { uartInitLines } from './lowering/uart.js';
34
- import { interruptInitLines } from './lowering/interrupts.js';
357
+ import { usbInitLines, usbdDeviceLines } from './lowering/usb.js';
358
+ /**
359
+ * STM32F4 boot-time DBGMCU setup: set DBG_SLEEP|DBG_STOP|DBG_STANDBY
360
+ * (DBGMCU_CR @ 0xE0042004, bits 0–2) so SWD stays attachable while the app
361
+ * sleeps. RCC_APB1ENR (0x40023840) bit 18 clocks the DBGMCU first — F4 gates
362
+ * register access behind it. Raw-register form (not the STM32 LL headers) so
363
+ * the shim stays include-light; AUTOSAR-clean via reinterpret_cast.
364
+ */
365
+ function stm32f4DbgmcuLines() {
366
+ return [
367
+ '// CUTTLEFISH_STM32_DBGMCU_BEGIN',
368
+ '#include <zephyr/init.h>',
369
+ 'static int __tc_stm32_dbgmcu_keep_swd_alive(void) {',
370
+ ' volatile uint32_t* const rcc_apb1enr = reinterpret_cast<volatile uint32_t*>(0x40023840);',
371
+ ' *rcc_apb1enr = *rcc_apb1enr | (1UL << 18);',
372
+ ' volatile uint32_t* const dbgmcu_cr = reinterpret_cast<volatile uint32_t*>(0xE0042004);',
373
+ ' *dbgmcu_cr = *dbgmcu_cr | 0x7u;',
374
+ ' return 0;',
375
+ '}',
376
+ 'SYS_INIT(__tc_stm32_dbgmcu_keep_swd_alive, PRE_KERNEL_1, 0);',
377
+ '// CUTTLEFISH_STM32_DBGMCU_END',
378
+ ];
379
+ }
380
+ import { interruptInitLines, collectInterruptPins } from './lowering/interrupts.js';
35
381
  import { wdtInitLines } from './lowering/wdt.js';
36
382
  import { bleInitLines } from './lowering/ble.js';
37
383
  import { wifiInitLines } from './lowering/wifi.js';
@@ -41,7 +387,6 @@ import { preferencesInitLines } from './lowering/preferences.js';
41
387
  import { randomInitLines } from './lowering/random.js';
42
388
  import { generateZephyrInitCode, generateZephyrBreakpointCode, generateZephyrLogpointCode } from './debug-codegen.js';
43
389
  import { generateStaticAsyncRuntime } from '@typecad/cuttlefish/api/shared';
44
- import { buildTimerPolyfill } from './async/timer-polyfill.js';
45
390
  import { resolveZephyrDisplayOp, newDisplayState } from './display/index.js';
46
391
  import { buildDisplayRuntime } from './display/gfx.js';
47
392
  import { ZEPHYR_DISPLAY_PROFILES, BUILT_IN_PROFILES } from './display/profiles.js';
@@ -51,11 +396,6 @@ export class ZephyrStrategy {
51
396
  constructor() {
52
397
  this.id = 'zephyr';
53
398
  this._largeEnumNames = new Set();
54
- // ── Worker offload backing (Phase 1) ─────────────────────────────────────
55
- // Delegates to the Zephyr backing (worker-backing.ts): k_work system
56
- // workqueue + k_sem for completion. k_sem provides the kernel memory barrier
57
- // the dual-core contract requires (the worker runs on a workqueue thread).
58
- this._workerBacking = buildZephyrWorkerBacking();
59
399
  // Display state (mirrors Arduino's _displayCtx). Seeded on display.init; the
60
400
  // validator-probe path seeds the default profile lazily.
61
401
  this._displayState = newDisplayState();
@@ -66,24 +406,36 @@ export class ZephyrStrategy {
66
406
  * by the methods that need the descriptor (shimLines, resolveHALOperation
67
407
  * via lowerHalOp).
68
408
  *
69
- * Tries to derive the chip descriptor from the board/MCU package's zephyr
70
- * fields (via boardConstants) first. Falls back to the hardcoded
71
- * chipForTarget registry for boards that haven't shipped zephyr config yet.
409
+ * The chip descriptor derives from the board/MCU manifest's zephyr
410
+ * fields (via boardConstants). There is no registry fallback — a board
411
+ * that never generated a manifest stays NO_BOARD_CHIP.
412
+ */
413
+ /**
414
+ * Eagerly resolve + cache the chip for `program`. The emitter calls this
415
+ * before rendering any bodies — hal-op lowering reads the module-global
416
+ * chip cache, and the lazy resolve (inside shimLines) runs later than the
417
+ * first lowered statement.
72
418
  */
419
+ prepareChip(program, ctx) {
420
+ this.resolveChip(ctx, program);
421
+ }
422
+ /**
423
+ * Contract-board generation: a custom PCB spec (typecad contract) names
424
+ * its wired pads and bus families; the SoC's bus controller labels come
425
+ * from the installed Zephyr tree's soc dtsi. Same builder as every board.
426
+ */
427
+ generateContractBoardModule(opts) {
428
+ return generateBoardModuleFromContract(opts);
429
+ }
73
430
  resolveChip(ctx, program) {
74
- // 1. Try board/MCU package constants (new path)
431
+ // The chip view reconstructs from the project's generated board
432
+ // manifest (boardgen's zephyr.* constants) — the one path, for every
433
+ // board. No curated registry, no target-based fallback: a program with
434
+ // no board data resolves NO_BOARD_CHIP and the lowering reports
435
+ // unsupported per subsystem.
75
436
  const fromBoard = resolveChipFromBoard(program?.boardConstants);
76
- if (fromBoard) {
77
- setActiveChip(fromBoard);
78
- return fromBoard;
79
- }
80
- // 2. Fall back to frameworkData.buildTarget → hardcoded registry
81
- const fd = ctx?.frameworkData;
82
- const target = fd?.target ??
83
- fd?.buildTarget;
84
- const chip = chipForTarget(target);
85
- setActiveChip(chip);
86
- return chip;
437
+ setActiveChip(fromBoard ?? NO_BOARD_CHIP);
438
+ return fromBoard ?? NO_BOARD_CHIP;
87
439
  }
88
440
  /**
89
441
  * Resolve the debug mode for the active target from the platform context.
@@ -113,6 +465,7 @@ export class ZephyrStrategy {
113
465
  // true so nothing is stripped — mirrors framework-esp32's forcedIncludes.
114
466
  const a = ctx?.analysis;
115
467
  const uses = (f) => (a ? !!a[f] : true);
468
+ const chip = this.resolveChip(ctx, _program);
116
469
  // <zephyr/drivers/gpio.h> and <cstdint> stay unconditional: gpio.h is
117
470
  // cross-cutting (gpio/power/interrupt/spi/pulse lowerings + the DT-spec
118
471
  // machinery all reference its API, and no single usesX flag owns it), and
@@ -126,12 +479,14 @@ export class ZephyrStrategy {
126
479
  // A program touching none of those needs no <cstdio>.
127
480
  const helpers = a?.usedPolyfillHelpers;
128
481
  const needsCstdio = uses('usesCstdio') || uses('usesFS') || uses('usesPreferences')
129
- || uses('usesUart')
482
+ || uses('usesUart') || uses('usesUsb')
130
483
  || !!helpers?.has('__tc_print') || !!helpers?.has('__tc_println');
131
484
  if (needsCstdio)
132
485
  inc.push('<cstdio>');
133
486
  if (uses('usesI2C'))
134
487
  inc.push('<zephyr/drivers/i2c.h>');
488
+ if (uses('usesSensor'))
489
+ inc.push('<zephyr/drivers/sensor.h>');
135
490
  if (uses('usesSPI'))
136
491
  inc.push('<zephyr/drivers/spi.h>');
137
492
  if (uses('usesUart'))
@@ -142,6 +497,17 @@ export class ZephyrStrategy {
142
497
  // emitted, so skip the include there to avoid pulling in an unused header.
143
498
  if (isPrintf && !inc.includes('<zephyr/drivers/uart.h>'))
144
499
  inc.push('<zephyr/drivers/uart.h>');
500
+ // USB CDC serial: the class instance is a UART device (uart.h); the
501
+ // device context macros + usbd_* API live in the next-stack header.
502
+ if (uses('usesUsb')) {
503
+ if (!inc.includes('<zephyr/drivers/uart.h>'))
504
+ inc.push('<zephyr/drivers/uart.h>');
505
+ inc.push('<zephyr/usb/usbd.h>');
506
+ // touch-to-reset shim callback reboots via NVIC_SystemReset()
507
+ // (RAM-retaining Cortex-M reset — no CONFIG_REBOOT needed).
508
+ if (chip?.usb?.touchReset)
509
+ inc.push('<cmsis_core.h>');
510
+ }
145
511
  if (uses('usesADC'))
146
512
  inc.push('<zephyr/drivers/adc.h>');
147
513
  if (uses('usesPWM'))
@@ -156,10 +522,8 @@ export class ZephyrStrategy {
156
522
  // Hardware timers via the counter driver.
157
523
  if (uses('usesHwtimer'))
158
524
  inc.push('<zephyr/drivers/counter.h>');
159
- if (uses('usesWDT'))
525
+ if (uses('usesWDT') || uses('usesWdt'))
160
526
  inc.push('<zephyr/drivers/watchdog.h>');
161
- if (uses('usesPower'))
162
- inc.push('<zephyr/pm/pm.h>', '<zephyr/pm/state.h>', '<zephyr/pm/policy.h>');
163
527
  // BLE: the bt_* GATT API + the flat-string headers the shim uses. <string>
164
528
  // is needed because a Utf8 (BleValueType.Utf8) read handler lowers to a
165
529
  // std::string-returning function (the string literal return type), and the
@@ -359,64 +723,178 @@ export class ZephyrStrategy {
359
723
  if (uses('usesNullishHelper')) {
360
724
  guardBody.push('template<typename T> inline bool cuttlefish_is_nullish(const T& v) { return false; }', 'inline bool cuttlefish_is_nullish(long long v) { return v == CUTTLEFISH_UNDEFINED; }', 'inline bool cuttlefish_is_nullish(int v) { return v == CUTTLEFISH_UNDEFINED; }', 'inline bool cuttlefish_is_nullish(double v) { return v == static_cast<double>(CUTTLEFISH_UNDEFINED); }', 'inline bool cuttlefish_is_nullish(bool v) { return v == false; }', 'template<typename T> inline bool cuttlefish_is_nullish(T* v) { return v == nullptr; }', 'template<typename T> inline bool cuttlefish_exists(const T& v) { return !cuttlefish_is_nullish(v); }', 'template<typename T, typename U> inline T cuttlefish_nullish(const T& a, U b) { return !cuttlefish_is_nullish(a) ? a : (T)b; }');
361
725
  }
362
- // millis() backed by the Zephyr uptime counter. uint32_t return matches
363
- // the Arduino API the shared runtime expects (wraps every ~49.7 days).
364
- // Kept when the program reads the clock itself — usesWallClock,
365
- // deliberately WITHOUT the delay() conflation usesMillis carries, because
366
- // Zephyr's delay lowers straight to k_msleep or has a hidden poller:
367
- // async functions / the async runtime, the setInterval/setTimeout
368
- // scheduler, or a mounted UI's per-frame tick.
726
+ // The runtime clock contract: __tc_now_ms() is the monotonic ms clock
727
+ // the shared runtimes (UI per-frame tick, async timers, scheduler)
728
+ // and currentTimeMillis() lower onto. Emitted whenever anything could
729
+ // read the clock: explicit Time calls, async, the interval/timeout
730
+ // scheduler, or a mounted UI. uint32_t wraps every ~49.7 days every
731
+ // consumer compares by subtraction, so the wrap is harmless.
369
732
  if (uses('usesWallClock') || uses('hasAsync') || (!a || a.timerCallCount > 0)
370
733
  || this.programUsesAsyncRuntime(program) || entryHasUI()) {
371
- guardBody.push('inline unsigned long millis() { return static_cast<unsigned long>(k_uptime_get_32()); }');
734
+ guardBody.push('inline uint32_t __tc_now_ms(void) { return k_uptime_get_32(); }');
372
735
  }
373
- // PROGMEM: only the (Arduino-oriented) UI runtime header can reference it.
374
- if (entryHasUI()) {
375
- guardBody.push('#ifndef PROGMEM', '#define PROGMEM', '#endif');
736
+ // Num fluent math (Num.abs/min/max) + the free math trio.
737
+ // On Zephyr newlib none of these
738
+ // names are macros, so the underscore-escape rewrite the
739
+ // strategy needs is not. abs(long)/min/max deliberately overload (not
740
+ // shadow) newlib's abs(int) — exact-match int calls still resolve to the
741
+ // libc overload.
742
+ if (uses('usesNum')) {
743
+ guardBody.push('struct __tc_Num {', ' static long abs(long x) { return x < 0 ? -x : x; }', ' static long min(long a, long b) { return a < b ? a : b; }', ' static long max(long a, long b) { return a > b ? a : b; }',
744
+ // The core emitter escapes abs/min/max member calls to a trailing
745
+ // underscore (C-library collision) — provide the escaped spellings so
746
+ // Num.abs_()/Num.min_()/Num.max_() resolve too.
747
+ ' static long abs_(long x) { return x < 0 ? -x : x; }', ' static long min_(long a, long b) { return a < b ? a : b; }', ' static long max_(long a, long b) { return a > b ? a : b; }', '} Num;', 'inline long abs(long v) { return v < 0 ? -v : v; }', 'inline long min(long a, long b) { return a < b ? a : b; }', 'inline long max(long a, long b) { return a > b ? a : b; }',
748
+ // Escaped free-function spellings — the escape applies to bare call
749
+ // sites on some paths (abs_(-42)); cover both.
750
+ 'inline long abs_(long v) { return v < 0 ? -v : v; }', 'inline long min_(long a, long b) { return a < b ? a : b; }', 'inline long max_(long a, long b) { return a > b ? a : b; }');
751
+ }
752
+ // Random PRNG shim: the Random namespace lowers to the __tc_rand_* PRNG.
753
+ // Emitted when the program actually uses the random.* ops, so a minimal
754
+ // program's shim stays empty. The dispatcher + PRNG helpers must precede
755
+ // the shims that call them (the guard block emits in push order, and
756
+ // C++ needs the definitions before use).
757
+ if (this.needsGpioReadShim(program, ctx)) {
758
+ guardBody.push(...emitGpioDevDispatcher(chip));
376
759
  }
377
- // map()/constrain() Arduino-API helpers — dead code unless called. The
378
- // setup emitter ORs entryHasUI() into usesConstrain before we see it (the
379
- // UI runtime's progress/range draw calls constrain).
380
- if (uses('usesMap')) {
381
- guardBody.push('inline long map(long x, long in_min, long in_max, long out_min, long out_max) { return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; }');
760
+ if (uses('usesRandom')) {
761
+ guardBody.push(...randomInitLines());
382
762
  }
383
- if (uses('usesConstrain')) {
384
- guardBody.push('inline long constrain(long x, long a, long b) { return x < a ? a : (x > b ? b : x); }');
763
+ // PROGMEM note: only some UI runtime headers reference it.
764
+ if (entryHasUI()) {
765
+ guardBody.push('#ifndef PROGMEM', '#define PROGMEM', '#endif');
385
766
  }
386
767
  // Test-runner console helpers: @typecad/expect's Zephyr shim calls these
387
768
  // for protocol output. Overloaded for string (const char*) and numeric
388
769
  // (double) so the same call site works for markers and test values.
389
- // Emitted only when the expect preprocessor actually injected the calls
390
- // (tracked as usedPolyfillHelpers).
391
- if (!a || !!helpers?.has('__tc_print') || !!helpers?.has('__tc_println')) {
392
- guardBody.push('inline void __tc_print(const char* s) { printf("%s", s); }', 'inline void __tc_print(double v) { printf("%g", v); }', 'inline void __tc_println(const char* s) { printf("%s\\n", s); }', 'inline void __tc_println(double v) { printf("%g\\n", v); }');
770
+ // The fs lowering bakes __tc_println into its error paths too, so the
771
+ // overloads are emitted unconditionally — ctx.analysis can be absent or
772
+ // stale on UI programs, and missing definitions would be a link error.
773
+ //
774
+ // The numeric form formats via INTEGER conversions only: libc float
775
+ // printf is not dependable across SDKs — the 0.17.5 toolchain swapped
776
+ // newlib for picolibc, whose default build silently prints NOTHING for
777
+ // %g (the same trap as newlib-nano's -u _printf_float), which emptied
778
+ // every [TC:EXPECT:...:value:] line. Integer %lld works in every libc
779
+ // configuration, and the host parser accepts plain fixed-point.
780
+ {
781
+ guardBody.push('inline void __tc_print(const char* s) { printf("%s", s); }', [
782
+ 'static inline char* __tc_fmt_num_buf(double v, char* out, size_t cap) {',
783
+ ' if (v != v) { snprintf(out, cap, "nan"); return out; }',
784
+ ' double a = v < 0 ? -v : v;',
785
+ ' long long ip = (long long)a;',
786
+ ' long long fr = (long long)((a - (double)ip) * 1000000.0 + 0.5);',
787
+ ' if (fr >= 1000000LL) { ip += 1LL; fr = 0LL; }',
788
+ ' int used = 0;',
789
+ ' if (v < 0 && (ip != 0LL || fr != 0LL)) { out[used++] = \'-\'; out[used] = \'\\0\'; }',
790
+ ' if (fr == 0LL) { snprintf(out + used, cap - (size_t)used, "%lld", ip); return out; }',
791
+ ' char fbuf[8];',
792
+ ' int len = snprintf(fbuf, sizeof(fbuf), "%06lld", fr);',
793
+ ' while (len > 0 && fbuf[len - 1] == \'0\') { fbuf[--len] = \'\\0\'; }',
794
+ ' snprintf(out + used, cap - (size_t)used, "%lld.%s", ip, fbuf);',
795
+ ' return out;',
796
+ '}',
797
+ 'static void __tc_fmt_num(double v) { char __b[32]; printf("%s", __tc_fmt_num_buf(v, __b, sizeof(__b))); }',
798
+ ].join('\n'), 'inline void __tc_print(double v) { __tc_fmt_num(v); }', 'inline void __tc_println(const char* s) { printf("%s\\n", s); }', 'inline void __tc_println(double v) { __tc_fmt_num(v); printf("\\n"); }');
799
+ }
800
+ // Serial-port write helper: writes a scalar to a UART/CDC device a byte at
801
+ // a time. Overloaded on const char* (strings, snprintf buffers) and double
802
+ // (numbers/booleans) so a single __tc_dev_put(dev, value) call site formats
803
+ // any writable scalar — the same overload contract __tc_print uses. Emitted
804
+ // when the program writes a UART or USB port (both include uart.h).
805
+ if (uses('usesUart') || uses('usesUsb')) {
806
+ guardBody.push('static inline void __tc_dev_put(const struct device* dev, const char* s) {', ' for (; *s != \'\\0\'; ++s) { uart_poll_out(dev, *s); }', '}', 'static inline void __tc_dev_put(const struct device* dev, double v) {', ' char __b[32];', ' __tc_dev_put(dev, __tc_fmt_num_buf(v, __b, sizeof(__b)));', '}');
393
807
  }
394
808
  // Per-peripheral bus state — gated on the same ctx.analysis.usesX flags as
395
809
  // forcedIncludes, so an unused peripheral emits no state (and its header is
396
- // not included). Mirrors framework-esp32's shimLines espInit block.
810
+ // not included). Mirrors framework-esp32's shimLines espInit block. Within
811
+ // a bus, only the instances the program drives get state — an unused
812
+ // declared controller's __tc_<bus>N_dev trips -Wunused-variable under
813
+ // Zephyr's -Werror (collectUsedBusIndices; probes with no program emit all).
814
+ const usedBuses = collectUsedBusIndices(program);
397
815
  if (uses('usesI2C') && chip.i2c) {
398
- for (let i = 0; i < chip.i2c.controllers.length; i++)
816
+ for (let i = 0; i < chip.i2c.controllers.length; i++) {
817
+ if (usedBuses && !usedBuses.i2c.has(i))
818
+ continue;
399
819
  guardBody.push(...i2cInitLines(chip, i));
820
+ }
400
821
  }
401
822
  if (uses('usesSPI') && chip.spi) {
402
- for (let i = 0; i < chip.spi.controllers.length; i++)
823
+ for (let i = 0; i < chip.spi.controllers.length; i++) {
824
+ if (usedBuses && !usedBuses.spi.has(i))
825
+ continue;
403
826
  guardBody.push(...spiInitLines(chip, i));
827
+ }
404
828
  }
405
829
  if (uses('usesUart') && chip.uart) {
406
- for (let i = 0; i < chip.uart.controllers.length; i++)
830
+ for (let i = 0; i < chip.uart.controllers.length; i++) {
831
+ if (usedBuses && !usedBuses.uart.has(i))
832
+ continue;
407
833
  guardBody.push(...uartInitLines(chip, i));
834
+ }
835
+ // Thin UART RX rings — one interrupt-drained ring per port the program
836
+ // reads (uart.rx_* ops). Emitted after the device handles it references.
837
+ const rings = collectUartRings(program);
838
+ if (rings) {
839
+ for (const r of rings.values()) {
840
+ guardBody.push(...uartRingStateLines(r.index, r.ring));
841
+ }
842
+ }
843
+ }
844
+ // DT-bound sensors — one state block per constructed part (device handle
845
+ // + sensor_value scratch). The lowering's __tc_sensor_* references and the
846
+ // overlay's DT child nodes derive from the same facts (lowering/sensor.ts).
847
+ if (uses('usesSensor')) {
848
+ const sensors = collectSensors(program);
849
+ if (sensors) {
850
+ for (const s of sensors.values()) {
851
+ guardBody.push(...sensorStateLines(s.part, s.bus, s.port, s.busKind, s.spiHz, s.spiMode, s.alertPin));
852
+ }
853
+ }
854
+ }
855
+ // Thin SPI targets — one spi_dt_spec per constructed target, against the
856
+ // DT child node the overlay emits. Same shared-facts discipline (the
857
+ // tc-spit-cfg comment is the overlay scanner's channel).
858
+ if (uses('usesSPI')) {
859
+ const targets = collectSpiTargets(program);
860
+ if (targets) {
861
+ for (const t of targets.values()) {
862
+ guardBody.push(...spiTargetStateLines(t.bus, t.cs, t.hz, t.mode));
863
+ }
864
+ }
865
+ }
866
+ // Thin Threads — one stack + k_thread + trampoline per started slot.
867
+ // Keyed on thread.start ops only: join() without a prior start() on the
868
+ // same index is a user error that surfaces as the undefined slot symbol.
869
+ {
870
+ const threads = collectThreads(program);
871
+ if (threads) {
872
+ for (const t of threads.values()) {
873
+ guardBody.push(...threadStateLines(t.instance, t.stackBytes));
874
+ }
875
+ }
876
+ }
877
+ if (uses('usesUsb') && chip.usb) {
878
+ guardBody.push(...usbdDeviceLines(chip));
879
+ for (let i = 0; i < chip.usb.cdcInstances; i++)
880
+ guardBody.push(...usbInitLines(chip, i));
881
+ }
882
+ // PWM init also fires (with alias vars for the override pins) when a
883
+ // program drives ONLY inline-override channels on a chip with no pwm
884
+ // facts — the lowered calls reference those aliases.
885
+ {
886
+ const pwmUserSpecs = collectPwmOverrideSpecs(program);
887
+ if ((uses('usesPWM') && chip.pwm) || pwmUserSpecs.length > 0) {
888
+ guardBody.push(...pwmInitLines(chip, collectUsedPins(program, 'pwm', chip), pwmUserSpecs));
889
+ }
408
890
  }
409
- if (uses('usesADC') && chip.adc)
410
- guardBody.push(...adcInitLines(chip));
411
- if (uses('usesPWM') && chip.pwm)
412
- guardBody.push(...pwmInitLines(chip));
413
891
  if (uses('usesDAC') && chip.dac)
414
892
  guardBody.push(...dacInitLines(chip));
415
893
  if (uses('usesHwtimer') && chip.hwtimer)
416
894
  guardBody.push(...hwtimerInitLines(chip));
417
895
  if (uses('usesInterrupts'))
418
- guardBody.push(...interruptInitLines(chip));
419
- if (uses('usesWDT') && chip.wdt)
896
+ guardBody.push(...interruptInitLines(chip, program ? collectInterruptPins(program) : undefined));
897
+ if ((uses('usesWDT') || uses('usesWdt')) && chip.wdt)
420
898
  guardBody.push(...wdtInitLines(chip));
421
899
  if (uses('usesBle'))
422
900
  guardBody.push(...bleInitLines());
@@ -447,8 +925,29 @@ export class ZephyrStrategy {
447
925
  guardBody.push(...preferencesInitLines());
448
926
  if (uses('usesFS'))
449
927
  guardBody.push(...fsInitLines());
450
- if (uses('usesRandom'))
451
- guardBody.push(...randomInitLines());
928
+ // The ADC init block also fires (with the override devices' handles)
929
+ // when a program uses ONLY inline-override reads on a chip with no adc
930
+ // facts — otherwise those reads reference undeclared device handles.
931
+ {
932
+ const adcOverrideDevices = collectAdcOverrideDevices(program);
933
+ if ((uses('usesADC') && chip.adc) || adcOverrideDevices.size > 0) {
934
+ guardBody.push(...adcInitLines(chip, collectUsedPins(program, 'adc'), adcOverrideDevices));
935
+ }
936
+ }
937
+ // STM32F4: keep the core debug port alive across WFI sleep. The DBGMCU
938
+ // gates PPB access while the core sleeps unless DBGMCU_CR DBG_SLEEP/
939
+ // DBG_STOP/DBG_STANDBY are set — without them openocd cannot examine or
940
+ // halt the running target ("Failed to read memory at 0xe000ed04", "AP
941
+ // write error, reset will not halt"), and with no RST pad on boards like
942
+ // the Black Pill the only recovery is the BOOT0 bootloader. Zephyr's
943
+ // CONFIG_STM32_ENABLE_DEBUG_SLEEP_STOP sets only DBG_STOP on F4 (the
944
+ // soc_config.c F1/L1 branch is the one that sets DBG_SLEEP), so the bits
945
+ // are set here at boot, unconditionally for dev boards.
946
+ // (soc is empty on board-resolved chips — the SoC name rides in the
947
+ // qualified id's variant segment, e.g. 'blackpill_f411ce/stm32f411xe'.)
948
+ if (chip.soc.startsWith('stm32f4') || /stm32f4\d*/.test(chip.id)) {
949
+ guardBody.push(...stm32f4DbgmcuLines());
950
+ }
452
951
  const lines = [];
453
952
  if (guardBody.length > 0) {
454
953
  lines.push('// cuttlefish runtime shim. Wrapped in a single include guard so the', '// block is safe to emit into multiple headers and .cpp files within', '// one translation unit (a .cpp may #include several headers that each', '// carry the shim). The guard ensures the definitions are seen exactly', '// once per TU.', '#ifndef CUTTLEFISH_SHIM_DEFINED', '#define CUTTLEFISH_SHIM_DEFINED', ...guardBody, '#endif // CUTTLEFISH_SHIM_DEFINED');
@@ -489,18 +988,19 @@ export class ZephyrStrategy {
489
988
  lines.push('#ifndef __TC_BP_DISABLED_DEFINED', '#define __TC_BP_DISABLED_DEFINED', 'static bool __tc_bp_disabled[256] = {0};', 'static inline bool __tc_bp_is_disabled(int id) { return id >= 0 && id < 256 && __tc_bp_disabled[id]; }',
490
989
  // Console input: poll the UART console device. DEVICE_DT_GET(DT_CHOSEN(zephyr_console))
491
990
  // resolves to the board's console (UART0 USB-CDC on the XIAO nRF52840).
492
- 'static inline char __tc_debug_wait_for_continue(int id) {', ' const struct device* __con = DEVICE_DT_GET(DT_CHOSEN(zephyr_console));', ' unsigned char __c = 0;', " while (uart_poll_in(__con, &__c) != 0) {", ' k_msleep(10);', ' }', " // Drain the rest of the typed line so the next breakpoint waits fresh.", " unsigned char __peek = 0;", " while (uart_poll_in(__con, &__peek) == 0 && __peek != '\\n') { (void)0; }", " if ((__c == 's') || (__c == 'S')) { if (id >= 0 && id < 256) __tc_bp_disabled[id] = true; }", ' return static_cast<char>(__c);', '}', '#endif // __TC_BP_DISABLED_DEFINED', '');
991
+ // Not every board DTS declares a zephyr,console chosen (STM32MP1 M-side,
992
+ // display/carrier boards): there is nothing to print a prompt on and
993
+ // nothing to read a key from, so breakpoints auto-continue instead of
994
+ // hanging an unattended run.
995
+ '#if DT_HAS_CHOSEN(zephyr_console)', 'static inline char __tc_debug_wait_for_continue(int id) {', ' const struct device* __con = DEVICE_DT_GET(DT_CHOSEN(zephyr_console));', ' unsigned char __c = 0;', " while (uart_poll_in(__con, &__c) != 0) {", ' k_msleep(10);', ' }', " // Drain the rest of the typed line so the next breakpoint waits fresh.", " unsigned char __peek = 0;", " while (uart_poll_in(__con, &__peek) == 0 && __peek != '\\n') { (void)0; }", " if ((__c == 's') || (__c == 'S')) { if (id >= 0 && id < 256) __tc_bp_disabled[id] = true; }", ' return static_cast<char>(__c);', '}', '#else', 'static inline char __tc_debug_wait_for_continue(int id) { (void)id; return static_cast<char>(0); }', '#endif // DT_HAS_CHOSEN(zephyr_console)', '#endif // __TC_BP_DISABLED_DEFINED', '');
493
996
  }
494
- // --- Zephyr entrypoint: main() runs setup()/loop() directly ---
495
- // The cuttlefish synthesizer emits setup() and loop() (it keys off
496
- // entrypointFunctionName()="setup" and requiresLoopFunction()=true). Zephyr
497
- // is a standard C main()-based RTOS, so main() bridges the two: it calls
498
- // setup() once, then loops loop() forever, yielding to the scheduler with
499
- // k_msleep(1) each iteration (cheap cooperative yield matches the esp32
500
- // app_main pattern). Declared extern here because setup/loop live in a
501
- // separate translation unit when generateHeaderFile() splits them into the
502
- // header.
503
- lines.push('extern void setup(void);', 'extern void loop(void);', '', 'int main(void) {', ' setup();', ' for (;;) {', ' loop();', ' k_msleep(1);', ' }', ' return 0;', '}');
997
+ // --- Zephyr entrypoint ---------------------------------------------------
998
+ // No bridge here: the synthesizer emits main() itself (it keys off
999
+ // entrypointFunctionName()="main" and requiresLoopFunction()=false), with
1000
+ // the top-level statements inlined. Per-frame driving (ui_tick, microtask
1001
+ // pumping, async task .run()) is appended to main() by the driver
1002
+ // machinery via asyncDriverFunctionName()="main" + hostEventLoop()see
1003
+ // asyncLoopInjection for the scheduler loop it closes over.
504
1004
  // GPIO read shim: emitted only when something actually reads a pin at
505
1005
  // runtime — user digitalRead() calls, the @typecad/safety voter (calls
506
1006
  // __tc_gpio_read directly), or the UI runtime header's digitalRead() poll
@@ -519,14 +1019,16 @@ export class ZephyrStrategy {
519
1019
  // dispatcher that resolves the owning controller's device per pin;
520
1020
  // single-controller SoCs collapse it to a one-liner. Each DT_NODELABEL is
521
1021
  // still compile-time-resolved per branch, so it is always statically valid.
1022
+ // (The dispatcher itself now lives at the top of the guard block — it must
1023
+ // precede the wiring-ambient shims and the PinGroup factory there; the
1024
+ // __tc_gpio_read body below is what still needs emitting here.)
522
1025
  if (this.needsGpioReadShim(program, ctx)) {
523
- lines.push(...emitGpioDevDispatcher(chip));
524
- lines.push('inline int __tc_gpio_read(int pin) { return gpio_pin_get_raw(__tc_gpio_dev(static_cast<uint32_t>(pin)), static_cast<gpio_pin_t>(pin)); }');
1026
+ lines.push('inline int __tc_gpio_read(int pin) { return gpio_pin_get_raw(__tc_gpio_dev(static_cast<uint32_t>(pin)), __tc_gpio_pin(static_cast<uint32_t>(pin))); }');
525
1027
  }
526
1028
  // __tc_gpio_write / __tc_delay_us are only referenced via @typecad/safety
527
1029
  // lowering, so they stay gated on it.
528
1030
  if (program && programUsesSafety(program)) {
529
- lines.push('inline void __tc_gpio_write(uint32_t pin, uint32_t value) { gpio_pin_set_raw(__tc_gpio_dev(pin), pin, value); }', '#ifndef __TC_DELAY_US_DEFINED', '#define __TC_DELAY_US_DEFINED', 'inline void __tc_delay_us(uint32_t us) { k_busy_wait(us); }', '#endif');
1031
+ lines.push('inline void __tc_gpio_write(uint32_t pin, uint32_t value) { gpio_pin_set_raw(__tc_gpio_dev(pin), __tc_gpio_pin(pin), value); }', '#ifndef __TC_DELAY_US_DEFINED', '#define __TC_DELAY_US_DEFINED', 'inline void __tc_delay_us(uint32_t us) { k_busy_wait(us); }', '#endif');
530
1032
  }
531
1033
  return lines;
532
1034
  }
@@ -539,11 +1041,16 @@ export class ZephyrStrategy {
539
1041
  // Collect the pins the program uses for output config, ADC reads, and
540
1042
  // interrupt attaches — deep-walking the IR the same way framework-esp32
541
1043
  // does (its profileDiagnostics walks program to find gpio.set_mode /
542
- // power.deep_sleep_pin / adc.read nodes).
1044
+ // adc.read nodes).
543
1045
  const outputPins = new Set();
544
1046
  const adcReadPins = new Set();
1047
+ const adcOverriddenPins = new Set();
1048
+ const pwmOverriddenPins = new Set();
545
1049
  const interruptPins = new Set();
1050
+ const wdtOps = new Set();
546
1051
  const dacPins = new Set();
1052
+ const pwmPins = new Set();
1053
+ const busInstances = { i2c: new Set(), spi: new Set(), uart: new Set() };
547
1054
  const hwtimerInstances = new Set();
548
1055
  let usesWifiOps = false;
549
1056
  let usesHttpOps = false;
@@ -558,16 +1065,51 @@ export class ZephyrStrategy {
558
1065
  && typeof op.pin === 'number') {
559
1066
  outputPins.add(op.pin);
560
1067
  }
561
- if ((op.operation === 'adc.read' || op.operation === 'adc.read_voltage')
1068
+ if ((op.operation === 'adc.read' || op.operation === 'adc.read_voltage'
1069
+ || op.operation === 'adc.read_raw' || op.operation === 'adc.read_mv')
562
1070
  && typeof op.pin === 'number') {
563
1071
  adcReadPins.add(op.pin);
1072
+ // Inline routing overrides (construction opts): the user vouches
1073
+ // for the pin — the unavailable-pin diagnostic does not apply.
1074
+ if ((typeof op.channelOverride === 'number' && op.channelOverride >= 0)
1075
+ || (typeof op.deviceOverride === 'string' && op.deviceOverride !== '')) {
1076
+ adcOverriddenPins.add(op.pin);
1077
+ }
564
1078
  }
565
- if (op.operation === 'interrupt.attach' && typeof op.pin === 'number') {
1079
+ if (op.operation === 'interrupt.attach_flags' && typeof op.pin === 'number') {
566
1080
  interruptPins.add(op.pin);
567
1081
  }
568
- if (op.operation === 'dac.write' && typeof op.pin === 'number') {
1082
+ if (typeof op.operation === 'string' && op.operation.startsWith('wdt.')) {
1083
+ wdtOps.add(op.operation);
1084
+ }
1085
+ if ((op.operation === 'dac.write' || op.operation === 'dac.write_value')
1086
+ && typeof op.pin === 'number') {
569
1087
  dacPins.add(op.pin);
570
1088
  }
1089
+ if ((op.operation === 'pwm.set_pulse' || op.operation === 'pwm.set_duty'
1090
+ || op.operation === 'pwm.set_period')
1091
+ && typeof op.pin === 'number') {
1092
+ pwmPins.add(op.pin);
1093
+ if ((typeof op.controllerOverride === 'string' && op.controllerOverride !== '')
1094
+ || (typeof op.channelOverride === 'number' && op.channelOverride >= 0)) {
1095
+ pwmOverriddenPins.add(op.pin);
1096
+ }
1097
+ }
1098
+ // Bus instance usage: which I2C/SPI/UART controller indexes the
1099
+ // program drives (the lowering resolves index N against
1100
+ // chip.<bus>.controllers[N]; an undeclared index emits references
1101
+ // to a state block that is never declared → link error).
1102
+ if (typeof op.operation === 'string') {
1103
+ const busMatch = op.operation.match(/^(i2c|spi|uart)\./);
1104
+ if (busMatch) {
1105
+ const bus = busMatch[1];
1106
+ const id = typeof op.bus === 'string' ? op.bus
1107
+ : typeof op.port === 'string' ? op.port : undefined;
1108
+ const m = id?.match(/(\d+)$/);
1109
+ if (m)
1110
+ busInstances[bus].add(parseInt(m[1], 10));
1111
+ }
1112
+ }
571
1113
  if (typeof op.operation === 'string' && op.operation.startsWith('hwtimer.')) {
572
1114
  const inst = typeof op.instance === 'number'
573
1115
  ? op.instance
@@ -596,41 +1138,54 @@ export class ZephyrStrategy {
596
1138
  };
597
1139
  visit(program);
598
1140
  // ── ADC pin validity ────────────────────────────────────────────────────
599
- // The SAADC lowering resolves a HAL pin to a channel via the chip
600
- // descriptor's adc.channels map. A pin not in that map resolves to -1,
601
- // which emits __tc_adc-1_setup() — an undefined symbol → link error. Flag
602
- // it at compile time with a clear message instead of an opaque link failure.
1141
+ // The SAADC lowering resolves the adc.read argument to a channel via the
1142
+ // chip descriptor's adc.channels map (pin-first, then channel-number
1143
+ // fallback for the Arduino-compat ADC.read(n) form see
1144
+ // adcChannelForPin). A number resolving to neither emits
1145
+ // __tc_adc-1_setup() — an undefined symbol → link error. Flag it at
1146
+ // compile time with a clear message instead of an opaque link failure.
603
1147
  const adcPins = new Set((chip.adc?.channels ?? []).map((c) => c.pin));
604
1148
  for (const pin of adcReadPins) {
605
- if (!adcPins.has(pin)) {
1149
+ if (adcOverriddenPins.has(pin))
1150
+ continue;
1151
+ if (adcChannelForPin(chip, pin) < 0) {
606
1152
  const valid = [...adcPins].sort((x, y) => x - y).join(', ');
1153
+ // Cross-peripheral suggestion: the facts know what this pin IS wired
1154
+ // to — a PWM-capable pin misread as analog is the classic mix-up.
1155
+ const pwmOnPin = (chip.pwm?.specs ?? []).find((s) => s.pin === pin);
1156
+ const mixup = pwmOnPin
1157
+ ? ` GPIO ${pin} carries PWM on this board (${pwmOnPin.controller} ch ${pwmOnPin.channel}) — did you mean new PWM(${pin}, …)?`
1158
+ : '';
607
1159
  diags.push({
608
1160
  severity: 'error',
609
1161
  code: 'zephyr-adc-pin-unavailable',
610
1162
  message: `GPIO ${pin} is not a SAADC channel on ${chip.id} and cannot be read with adc.read.`,
611
1163
  hint: valid
612
- ? `Use an analog-capable pin. On ${chip.id} (SAADC): ${valid}.`
613
- : `This target has no ADC channels mapped in its chip descriptor.`,
1164
+ ? `Use an analog-capable pin. On ${chip.id} (SAADC): ${valid}.${mixup}`
1165
+ : `This target has no ADC channels mapped in its chip descriptor.${mixup}`,
614
1166
  source: program.fileName,
615
1167
  });
616
1168
  }
617
1169
  }
618
1170
  // ── Interrupt pin validity ──────────────────────────────────────────────
619
- // interrupt.attach only wires a real callback for pins listed in the chip
620
- // descriptor's gpio.interruptPins (the lowering needs a DT spec to build
621
- // the gpio_callback struct at init). An attach on an unlisted pin emits
622
- // only a comment silent no-op. Flag it so the user knows the handler
623
- // will never fire.
1171
+ // GPIO.onInterrupt works on every REAL GPIO: pins listed in the chip
1172
+ // descriptor's gpio.interruptPins wire through the DT-spec chain, any
1173
+ // other in-range pin through the raw-controller chain. What cannot work
1174
+ // is a pin number no declared controller range covers (e.g. 99 on a
1175
+ // 34-pin SoC) — that lowering is a comment (silent no-op). Flag only
1176
+ // that case.
624
1177
  const intPins = new Set((chip.gpio.interruptPins ?? []).map((p) => p.pin));
625
1178
  for (const pin of interruptPins) {
626
- if (!intPins.has(pin)) {
1179
+ if (intPins.has(pin))
1180
+ continue;
1181
+ const inRange = !chip.gpioControllers || chip.gpioControllers.length === 0
1182
+ || chip.gpioControllers.some((r) => pin >= r.minPin && pin <= r.maxPin);
1183
+ if (!inRange) {
627
1184
  diags.push({
628
1185
  severity: 'error',
629
1186
  code: 'zephyr-interrupt-pin-unavailable',
630
- message: `GPIO ${pin} has no interrupt DT spec on ${chip.id}; interrupt.attach is a no-op.`,
631
- hint: intPins.size > 0
632
- ? `Add the pin to the chip descriptor's gpio.interruptPins, or use an interrupt-capable pin: ${[...intPins].sort((x, y) => x - y).join(', ')}.`
633
- : `This target declares no interrupt pins in its chip descriptor; interrupts are not available.`,
1187
+ message: `GPIO ${pin} does not exist on ${chip.id}; onInterrupt is a no-op.`,
1188
+ hint: `Use a real GPIO on this board (controller ranges: ${chip.gpioControllers.map((r) => `${r.nodelabel} ${r.minPin}-${r.maxPin}`).join(', ')}).`,
634
1189
  source: program.fileName,
635
1190
  });
636
1191
  }
@@ -645,13 +1200,82 @@ export class ZephyrStrategy {
645
1200
  const dacChannels = new Set((chip.dac?.channels ?? []).map((c) => c.pin));
646
1201
  for (const pin of dacPins) {
647
1202
  if (!dacChannels.has(pin)) {
1203
+ const adcCh = (chip.adc?.channels ?? []).find((c) => c.pin === pin);
1204
+ const mixup = adcCh
1205
+ ? ` GPIO ${pin} is an ADC channel (${adcCh.controller ?? chip.adc?.nodeLabel ?? 'adc'} ch ${adcCh.channel}) — did you mean new ADC(${pin})?`
1206
+ : '';
648
1207
  diags.push({
649
1208
  severity: 'error',
650
1209
  code: 'zephyr-dac-pin-unavailable',
651
1210
  message: `GPIO ${pin} is not a DAC channel on ${chip.id} and cannot be driven with dac.write.`,
652
1211
  hint: dacChannels.size > 0
653
- ? `Use a DAC-capable pin. On ${chip.id}: ${[...dacChannels].sort((x, y) => x - y).join(', ')}.`
654
- : `${chip.id} has no DAC. Use an esp32_devkitc target (ESP32 DAC on GPIO25/26).`,
1212
+ ? `Use a DAC-capable pin. On ${chip.id}: ${[...dacChannels].sort((x, y) => x - y).join(', ')}.${mixup}`
1213
+ : `${chip.id} has no DAC. Use an esp32_devkitc target (ESP32 DAC on GPIO25/26).${mixup}`,
1214
+ source: program.fileName,
1215
+ });
1216
+ }
1217
+ }
1218
+ }
1219
+ // ── PWM pin validity ────────────────────────────────────────────────────
1220
+ // pwm.write resolves a HAL pin to a DT spec via the chip descriptor's
1221
+ // pwm.specs (static) or pwm.matrix (ESP32 LEDC: any matrix pin synthesizes
1222
+ // a spec at build time). A pin on neither lowers to a comment — the pin
1223
+ // silently never toggles. Flag it so the user knows (warning, not error:
1224
+ // boards legitimately ship partial PWM coverage, e.g. only the aliased LED
1225
+ // channel, and the rest of the program still works).
1226
+ const pwmMatrix = chip.pwm?.matrix;
1227
+ const pwmSpecPins = new Set([
1228
+ ...(chip.pwm?.specs ?? []).map((s) => s.pin),
1229
+ ...(pwmMatrix?.pins ?? []),
1230
+ ]);
1231
+ const pwmValid = [...pwmSpecPins].sort((x, y) => x - y).join(', ');
1232
+ for (const pin of pwmPins) {
1233
+ if (pwmOverriddenPins.has(pin))
1234
+ continue;
1235
+ if (!pwmSpecPins.has(pin)) {
1236
+ // Cross-peripheral suggestion: an analog pin driven as PWM is the
1237
+ // other classic mix-up.
1238
+ const adcCh = (chip.adc?.channels ?? []).find((c) => c.pin === pin);
1239
+ const mixup = adcCh
1240
+ ? ` GPIO ${pin} is an ADC channel (${adcCh.controller ?? chip.adc?.nodeLabel ?? 'adc'} ch ${adcCh.channel}) — did you mean new ADC(${pin})?`
1241
+ : '';
1242
+ diags.push({
1243
+ severity: 'warning',
1244
+ code: 'zephyr-pwm-pin-unavailable',
1245
+ message: pwmMatrix
1246
+ ? `pwm on GPIO ${pin} lowers to a no-op: the pin is outside ${chip.id}'s PWM-capable set (USB, flash/PSRAM, strapping, and console pads are excluded), so nothing is driven.`
1247
+ : `pwm on GPIO ${pin} lowers to a no-op: the pin has no PWM spec in ${chip.id}'s chip descriptor, so nothing is driven.`,
1248
+ hint: pwmValid
1249
+ ? pwmMatrix
1250
+ ? `PWM-capable pins on ${chip.id} (first ${pwmMatrix.channelCount} driven get channels): ${pwmValid}.${mixup}`
1251
+ : `PWM-capable pins on ${chip.id}: ${pwmValid}.${mixup}`
1252
+ : `${chip.id} maps no PWM channels in its chip descriptor — pwm.* are no-ops on this target.${mixup}`,
1253
+ source: program.fileName,
1254
+ });
1255
+ }
1256
+ }
1257
+ // ── Bus instance validity ───────────────────────────────────────────────
1258
+ // The bus lowerings resolve instance N against chip.<bus>.controllers[N]
1259
+ // and emit `__tc_<bus>N_dev` references; the state block is only declared
1260
+ // for declared controllers, so an out-of-range instance is an undefined
1261
+ // symbol at link time. Flag it here with the board's real controller set
1262
+ // instead (mirrors how the ADC gate replaces an opaque link error).
1263
+ const busLabels = {
1264
+ i2c: { name: 'I2C', label: 'Wire' },
1265
+ spi: { name: 'SPI', label: 'SPI' },
1266
+ uart: { name: 'UART', label: 'Serial' },
1267
+ };
1268
+ for (const bus of ['i2c', 'spi', 'uart']) {
1269
+ const controllers = chip[bus]?.controllers ?? [];
1270
+ for (const inst of busInstances[bus]) {
1271
+ if (inst < 0 || inst >= controllers.length) {
1272
+ diags.push({
1273
+ severity: 'error',
1274
+ code: 'zephyr-bus-instance-unavailable',
1275
+ message: `${busLabels[bus].name}${inst} is used but ${chip.id} declares only ${controllers.length} ${busLabels[bus].name} controller(s) in its Zephyr chip data — the generated code would not link.`,
1276
+ hint: controllers.length > 0
1277
+ ? `Use ${busLabels[bus].name}0 (the board's default-enabled controller: ${controllers.map((c) => c.nodeLabel).join(', ')}).`
1278
+ : `${chip.id} declares no ${busLabels[bus].name} controllers for Zephyr.`,
655
1279
  source: program.fileName,
656
1280
  });
657
1281
  }
@@ -684,12 +1308,34 @@ export class ZephyrStrategy {
684
1308
  }
685
1309
  }
686
1310
  }
1311
+ // ── Watchdog availability ───────────────────────────────────────────────
1312
+ // wdt.* resolves the device via the chip descriptor's wdt.nodeLabel. A
1313
+ // chip without that entry (e.g. SAM D21 — Zephyr's samd21 dtsi exposes
1314
+ // no watchdog node) lowers to a comment — flag it so the user knows the
1315
+ // watchdog never arms.
1316
+ if (wdtOps.size > 0 && !chip.wdt) {
1317
+ diags.push({
1318
+ severity: 'error',
1319
+ code: 'zephyr-wdt-unavailable',
1320
+ message: `Watchdog ops are used but ${chip.id} exposes no watchdog device.`,
1321
+ hint: `${chip.id} declares no wdt.nodeLabel (Zephyr's devicetree for this SoC has no watchdog node). Use a target with a watchdog, or drop the wdt.* calls.`,
1322
+ source: program.fileName,
1323
+ });
1324
+ }
1325
+ // ── Radio presence: family-derived, not board-curated ───────────────────
1326
+ // Espressif ESP32 variants carry a 2.4GHz WiFi radio; every other Zephyr
1327
+ // family in the catalog is radioless (until an Ethernet/board-wifi fact
1328
+ // exists). Derived from the soc name — or, when no board module was
1329
+ // generated, from the raw build target the config carries.
1330
+ const familyTarget = `${chip.soc || ''} ${ctx?.frameworkData?.target ?? ''} ${ctx?.frameworkData?.buildTarget ?? ''}`.toLowerCase();
1331
+ const wifiSupported = chip.wifi?.supported
1332
+ ?? familyTarget.split(/[^a-z0-9]+/).some((t) => t.startsWith('esp32'));
687
1333
  // ── WiFi target validity ────────────────────────────────────────────────
688
1334
  // WiFi ops require a chip with a WiFi radio. The ESP32-S3 descriptor sets
689
1335
  // wifi.supported; the XIAO nRF52840 omits it (no radio). Flag wifi usage on
690
1336
  // a radioless chip so the user gets a clear "use an ESP32 target" message
691
1337
  // instead of an opaque link/DT failure.
692
- if (usesWifiOps && !chip.wifi?.supported) {
1338
+ if (usesWifiOps && !wifiSupported) {
693
1339
  diags.push({
694
1340
  severity: 'error',
695
1341
  code: 'zephyr-wifi-unavailable-on-target',
@@ -706,7 +1352,7 @@ export class ZephyrStrategy {
706
1352
  // gets a clear "use an ESP32 target" message instead of an opaque link or
707
1353
  // runtime failure. (HTTP rides over WiFi here; an Ethernet target would
708
1354
  // set wifi.supported via a different transport flag if/when added.)
709
- if (usesHttpOps && !chip.wifi?.supported) {
1355
+ if (usesHttpOps && !wifiSupported) {
710
1356
  diags.push({
711
1357
  severity: 'error',
712
1358
  code: 'zephyr-http-unavailable-on-target',
@@ -718,7 +1364,7 @@ export class ZephyrStrategy {
718
1364
  // ── MQTT target validity ─────────────────────────────────────────────
719
1365
  // Same constraint as HTTP: MQTT needs a network transport to reach a broker.
720
1366
  // Flag mqtt usage on a radioless chip so the user picks a networked target.
721
- if (usesMqttOps && !chip.wifi?.supported) {
1367
+ if (usesMqttOps && !wifiSupported) {
722
1368
  diags.push({
723
1369
  severity: 'error',
724
1370
  code: 'zephyr-mqtt-unavailable-on-target',
@@ -734,32 +1380,45 @@ export class ZephyrStrategy {
734
1380
  return diags;
735
1381
  }
736
1382
  /**
737
- * `loop` is forward-declared `extern` by the main() bridge shim
738
- * (extern void loop(void);int main(void) {...}), so emitting a
739
- * `static void loop()` definition redeclares it with conflicting linkage,
740
- * which GCC rejects. Exclude it from the static forward-declaration path
741
- * mirrors how ArduinoStrategy excludes `loop` (the Arduino core forward-
742
- * declares it extern). `setup` is already handled because it equals
1383
+ * Strip Arduino library headers that @typecad/hal's class surfaces register
1384
+ * (I2CBus <Wire.h>, SPIBus <SPI.h>). The Zephyr
1385
+ * equivalents are usage-gated forcedIncludes (<zephyr/drivers/i2c.h>, …),
1386
+ * so an unstripped Wire.h reaches the west build and gcc rejects it
1387
+ * ("Wire.h: No such file or directory") this hook is why I2C/SPI class
1388
+ * usage on Zephyr used to fail at compile time.
1389
+ */
1390
+ filterRequiredIncludes(includes) {
1391
+ // Arduino-core / AVR-only headers the HAL classes declare via __includes.
1392
+ // On Zephyr the watchdog lowers to the Zephyr driver (<zephyr/drivers/
1393
+ // watchdog.h>, forced under usesWDT) — <avr/wdt.h> does not exist there.
1394
+ const arduinoOnlyHeaders = new Set(['<Wire.h>', '<SPI.h>', '<avr/wdt.h>', '<avr/io.h>', '<avr/interrupt.h>', '<avr/sleep.h>', '<avr/power.h>']);
1395
+ return includes.filter((i) => !arduinoOnlyHeaders.has(i));
1396
+ }
1397
+ /**
1398
+ * Nothing to exclude: no shim or Zephyr core header forward-declares a user
1399
+ * function extern (the old setup()/loop() bridge was the only consumer of
1400
+ * this hook, excluding `loop`). The entrypoint itself (`main`) is already
1401
+ * excluded from the static forward-declaration path because it equals
743
1402
  * entrypointFunctionName().
744
1403
  */
745
1404
  forwardDeclarationExclusions() {
746
- return ['loop'];
1405
+ return [];
747
1406
  }
748
1407
  // ── File shape ──────────────────────────────────────────────────────────
749
1408
  sourceExtension() {
750
1409
  return 'cpp';
751
1410
  }
752
1411
  entrypointFunctionName() {
753
- return 'setup';
1412
+ return 'main';
754
1413
  }
755
1414
  requiresLoopFunction() {
756
- return true;
1415
+ return false;
757
1416
  }
758
1417
  overrideBaseName(originalBaseName, outDirBaseName, isEntryFile, isNpmPackage) {
759
1418
  // npm packages are library-style — don't rename. Entry files (non-npm) take
760
- // the out-dir name (mirrors Arduino's .ino-must-match-dir rule). Everything
761
- // else passes through. (The manifest's entrypoint.overrideBaseName field is
762
- // dead — never read in src/ — so this method is the sole name source.)
1419
+ // the out-dir name (mirrors the directory-name-must-match-entry rule).
1420
+ // Everything else passes through. (The manifest's entrypoint.overrideBaseName
1421
+ // field is dead — never read in src/ — so this method is the sole name source.)
763
1422
  if (isNpmPackage)
764
1423
  return originalBaseName;
765
1424
  if (isEntryFile)
@@ -767,8 +1426,8 @@ export class ZephyrStrategy {
767
1426
  return originalBaseName;
768
1427
  }
769
1428
  effectiveEmitMode(requestedMode, _isNpmPackage) {
770
- // Zephyr always emits .cpp (no .ino equivalent to force away from), so this
771
- // is passthrough regardless of npm/app. The 2-param shape matches the
1429
+ // Zephyr always emits .cpp (no single-file entry equivalent to force away
1430
+ // from), so this is passthrough regardless of npm/app. The 2-param shape
772
1431
  // interface and Arduino; behavior is identical across branches.
773
1432
  return requestedMode;
774
1433
  }
@@ -788,8 +1447,6 @@ export class ZephyrStrategy {
788
1447
  mapReturnType(functionName, returnType) {
789
1448
  if (functionName === 'main')
790
1449
  return 'int';
791
- if (functionName === 'setup' || functionName === 'loop')
792
- return 'void';
793
1450
  return this.normalizeCppType(returnType);
794
1451
  }
795
1452
  isStringLikeType(cppType) {
@@ -877,26 +1534,6 @@ export class ZephyrStrategy {
877
1534
  // A throw becomes an infinite halt loop instead of a real throw.
878
1535
  return 'for (;;) { k_msleep(1000); }';
879
1536
  }
880
- isConsoleCall(callee) {
881
- return callee.startsWith('console.');
882
- }
883
- transformConsoleCall(method, renderedArgs, forHeader) {
884
- const semi = forHeader ? '' : ';';
885
- const empty = !renderedArgs || renderedArgs.trim() === '';
886
- const tag = method === 'error' ? '[ERROR] ' : method === 'warn' ? '[WARN] ' : '';
887
- if (empty)
888
- return `printk("%s\\n", "${tag}")${semi}`;
889
- const parts = renderedArgs.split(' << ');
890
- if (parts.length === 1) {
891
- return `printk("%s%s\\n", "${tag}", (${renderedArgs}))${semi}`;
892
- }
893
- const fmt = '%s' + '%s'.repeat(parts.length) + '\\n';
894
- const args = [`"${tag}"`, ...parts].join(', ');
895
- return `printk("${fmt}", ${args})${semi}`;
896
- }
897
- transformConsoleExpression(_method, _renderedArgs) {
898
- return undefined;
899
- }
900
1537
  objectFieldInitializer() {
901
1538
  return undefined;
902
1539
  }
@@ -933,18 +1570,6 @@ export class ZephyrStrategy {
933
1570
  reason: 'delayMicroseconds() busy-waits the CPU for the full delay, stalling every lower-priority interrupt and the scheduler for its duration',
934
1571
  severity: 'warning',
935
1572
  }],
936
- ['console.log', {
937
- reason: 'console output lowers to printk(), which is ISR-legal but slow and lock-protected (CONFIG_PRINTK_SYNC) — it adds jitter to every interrupt behind it',
938
- severity: 'info',
939
- }],
940
- ['console.error', {
941
- reason: 'console output lowers to printk(), which is ISR-legal but slow and lock-protected (CONFIG_PRINTK_SYNC) — it adds jitter to every interrupt behind it',
942
- severity: 'info',
943
- }],
944
- ['console.warn', {
945
- reason: 'console output lowers to printk(), which is ISR-legal but slow and lock-protected (CONFIG_PRINTK_SYNC) — it adds jitter to every interrupt behind it',
946
- severity: 'info',
947
- }],
948
1573
  ['I2C0', {
949
1574
  reason: 'I2C transactions may sleep (driver locking + clock stretching) and are not callable from Zephyr interrupt context',
950
1575
  severity: 'warning',
@@ -1024,17 +1649,16 @@ export class ZephyrStrategy {
1024
1649
  return false;
1025
1650
  }
1026
1651
  mathHeader() {
1027
- // <math.h> is the schema-permitted value (the manifest enum allows none |
1028
- // <math.h> | <Arduino.h>). Zephyr's toolchain provides it; the C++ <cmath>
1029
- // names are available via it as well.
1030
- return '<math.h>';
1652
+ // <cmath>, not <math.h>: the shared lowering emits Math.<fn>() as
1653
+ // std::<fn>() (expression-to-ir), and picolibc's <math.h> declares the
1654
+ // C names in the global namespace only — std::round et al. fail to
1655
+ // compile. Builds run with CONFIG_REQUIRES_FULL_LIBCPP (real libstdc++),
1656
+ // whose <cmath> provides the std:: names.
1657
+ return '<cmath>';
1031
1658
  }
1032
1659
  cstringHeader() {
1033
1660
  return '<cstring>';
1034
1661
  }
1035
- needsVectorOverload() {
1036
- return true;
1037
- }
1038
1662
  needsLargeEnumUnderlying() {
1039
1663
  return false;
1040
1664
  }
@@ -1046,43 +1670,109 @@ export class ZephyrStrategy {
1046
1670
  return undefined;
1047
1671
  }
1048
1672
  // ── Async ───────────────────────────────────────────────────────────────
1049
- // Hybrid: timers are native (k_timer + k_work, see src/async/timer-polyfill.ts);
1050
- // Promises use the heap-free static runtime (generateStaticAsyncRuntime), pumped
1051
- // cooperatively in loop() via cuttlefish_pump_microtasks(). There is no
1052
- // __tc_timer_runtime.run() poll native timers fire from their own expiry path.
1673
+ // Promises use the heap-free static runtime (generateStaticAsyncRuntime),
1674
+ // pumped cooperatively via cuttlefish_pump_microtasks(). There is no
1675
+ // __tc_timer_runtime.run() poll — periodic work is a Thread (k_thread) or a
1676
+ // Counter (hardware timer), never a cooperative timer queue.
1053
1677
  getAsyncRuntimeConfig() {
1054
1678
  return {
1055
1679
  queueCapacity: 64,
1056
1680
  scheduler: 'microtask',
1057
1681
  waitForPinEdge: 'stub',
1058
1682
  hasPromiseRuntime: true,
1059
- hasTimers: true,
1683
+ hasTimers: false,
1060
1684
  // Static (heap-free) runtime — no STL headers required.
1061
1685
  requiredIncludes: [],
1062
1686
  };
1063
1687
  }
1688
+ /**
1689
+ * Board module generation: join the Zephyr board data pack (extracted
1690
+ * from the pinned tree) with the curated soc descriptors. See
1691
+ * src/boardgen.ts.
1692
+ */
1693
+ generateBoardModule(target, opts) {
1694
+ try {
1695
+ const g = generateBoard(target, { factsJson: opts?.factsJson, asBuiltJson: opts?.asBuiltJson });
1696
+ return { boardTs: g.boardTs, boardJson: g.boardJson, ...(g.warnings ? { warnings: g.warnings } : {}) };
1697
+ }
1698
+ catch (err) {
1699
+ // A malformed facts/as-built file is the USER's error — surface it
1700
+ // verbatim instead of the generic "cannot generate" below.
1701
+ if (err instanceof Error && (err.message.includes('cuttlefish.facts.json') || err.message.includes('as-built.json')))
1702
+ throw err;
1703
+ return undefined;
1704
+ }
1705
+ }
1706
+ /**
1707
+ * Regenerate the board catalog overlay from the user's own Zephyr tree —
1708
+ * `cuttlefish board sync`. After a `west update`, this is how new/changed/
1709
+ * removed boards reach projects without a cuttlefish release. See
1710
+ * src/sdk/board-catalog-sync.ts.
1711
+ */
1712
+ syncBoardCatalog(zephyrBase) {
1713
+ return syncBoardCatalog(zephyrBase ? { zephyrBase } : {});
1714
+ }
1715
+ /**
1716
+ * Refresh the overlay only when it is stale (provenance no longer matches
1717
+ * the tree) — the pre-step `cuttlefish board regen` runs so a regen after
1718
+ * `west update` picks up the tree's boards automatically. Tree walk only
1719
+ * happens when there is actual refreshing to do.
1720
+ */
1721
+ ensureFreshBoardCatalog() {
1722
+ return ensureFreshBoardCatalog();
1723
+ }
1064
1724
  asyncLoopInjection(taskVarNames, configOrBool, _hasTimers) {
1065
1725
  const cfg = typeof configOrBool === 'boolean'
1066
1726
  ? { hasPromiseRuntime: configOrBool, hasTimers: _hasTimers ?? false }
1067
1727
  : configOrBool;
1068
- // Drive every async state-machine task once per loop() iteration. The task
1069
- // globals auto-start on their first .run() (constructor sets STATE_0, which
1070
- // runs unconditionally), so this is both the start and the per-frame advance.
1071
- // The state machine no-ops in its terminal/cyclic state, so unconditional
1072
- // .run() is correct (mirrors framework-arduino). No isComplete() gating.
1073
- const lines = [];
1728
+ // Drive every async state-machine task once per scheduler iteration. The
1729
+ // task globals auto-start on their first .run() (constructor sets STATE_0,
1730
+ // which runs unconditionally), so this is both the start and the per-frame
1731
+ // advance. The state machine no-ops in its terminal/cyclic state, so
1732
+ // unconditional .run() is correct (mirrors framework-arduino). No
1733
+ // isComplete() gating.
1734
+ const work = [];
1074
1735
  if (cfg.hasPromiseRuntime) {
1075
- lines.push('cuttlefish_pump_microtasks();');
1736
+ work.push('cuttlefish_pump_microtasks();');
1076
1737
  }
1077
1738
  for (const n of taskVarNames) {
1078
- lines.push(`${n}.run();`);
1739
+ work.push(`${n}.run();`);
1079
1740
  }
1080
- // NOTE: no __tc_timer_runtime.run()Zephyr timers are native k_timer
1081
- // (timer-polyfill.ts), not a cooperative poll.
1082
- return lines;
1741
+ // NOTE: no timer-queue poll periodic work is a Thread (k_thread) or a
1742
+ // Counter (hardware timer), never a cooperative poll.
1743
+ //
1744
+ // main() runs once, so the per-frame work must close over its own loop.
1745
+ // Under a mounted UI the emitter's hostEventLoop() already wraps ui_tick +
1746
+ // these lines in `while (...) { ...; k_msleep(1); }` — return flat lines
1747
+ // there. Otherwise (async/timer programs with no UI) self-wrap the same
1748
+ // scheduler loop here. For a program whose top-level `while (true)` never
1749
+ // exits, the loop below it is unreachable — harmless, and identical to the
1750
+ // old bridge behavior (whose for(;;) around loop() was equally dead).
1751
+ if (entryHasUI()) {
1752
+ return work;
1753
+ }
1754
+ return ['for (;;) {', ...work.map((l) => ` ${l}`), ' k_msleep(1);', '}'];
1083
1755
  }
1084
1756
  asyncDriverFunctionName() {
1085
- return 'loop';
1757
+ return 'main';
1758
+ }
1759
+ /**
1760
+ * Zephyr's scheduler loop for event-driven programs (UI mounted). The
1761
+ * emitter wraps the per-frame work — ui_tick plus the asyncLoopInjection
1762
+ * lines — in `while (__tc_zephyr_event_loop) { ...; k_msleep(1); }`: a
1763
+ * 1 ms cooperative yield to the kernel each frame, matching the cadence the
1764
+ * old setup()/loop() bridge gave loop(). The flag is never cleared — the
1765
+ * loop is the program's lifetime — but keeping the bool gives the condition
1766
+ * a used symbol (a bare `while (true)` would leave nothing to name, and an
1767
+ * unused flag would trip -Werror's -Wunused-variable).
1768
+ */
1769
+ hostEventLoop() {
1770
+ return {
1771
+ flagName: '__tc_zephyr_event_loop',
1772
+ continueCondition: '__tc_zephyr_event_loop',
1773
+ preIteration: '',
1774
+ postIteration: 'k_msleep(1);',
1775
+ };
1086
1776
  }
1087
1777
  // ── Type aliases ────────────────────────────────────────────────────────
1088
1778
  shouldSkipTypeAlias() {
@@ -1093,7 +1783,7 @@ export class ZephyrStrategy {
1093
1783
  return [];
1094
1784
  }
1095
1785
  currentTimeMillis() {
1096
- return 'millis()';
1786
+ return '__tc_now_ms()';
1097
1787
  }
1098
1788
  // ── Build configuration ──────────────────────────────────────────────────
1099
1789
  asyncQueueCapacity() {
@@ -1136,12 +1826,10 @@ export class ZephyrStrategy {
1136
1826
  // cuttlefish_halt: always (the runtime header may reference it).
1137
1827
  // string_methods / static_array: STL-free array + string helpers a no-STL
1138
1828
  // target needs (mutated/struct array literals + any string method).
1139
- // timer_methods: k_timer/k_work pool for setInterval/setTimeout (gated on
1140
- // timerCallCount at emit time in generateNativePolyfills).
1141
1829
  // async_runtime: heap-free static Promise/microtask runtime (no STL needed).
1142
1830
  return new Set([
1143
1831
  'cuttlefish_halt', 'wiring_compat', 'string_methods', 'static_array',
1144
- 'timer_methods', 'async_runtime',
1832
+ 'async_runtime',
1145
1833
  ]);
1146
1834
  }
1147
1835
  generateNativePolyfills(program, ctx) {
@@ -1269,22 +1957,6 @@ struct __tc_StaticArray {
1269
1957
  dependencies: [],
1270
1958
  },
1271
1959
  ];
1272
- // Worker-offload runtime (Phase 1). Emitted only when the program uses
1273
- // worker.* ops, backed by the Zephyr primitives in worker-backing.ts
1274
- // (k_work system workqueue + k_sem for the completion barrier).
1275
- const usesWorker = !!(ctx?.analysis?.usesWorker);
1276
- if (program && usesWorker) {
1277
- const workerPoly = buildWorkerRuntimePolyfill(program, this, buildZephyrWorkerBacking(), { poolSize: 4 });
1278
- if (workerPoly)
1279
- polyfills.push(workerPoly);
1280
- }
1281
- // timer_methods — k_timer/k_work pool. Gated on observed timer call count;
1282
- // a program with no setInterval/setTimeout emits nothing.
1283
- const analysis = ctx?.analysis;
1284
- const timerCallCount = analysis?.timerCallCount ?? 0;
1285
- if (timerCallCount > 0) {
1286
- polyfills.push(buildTimerPolyfill(timerCallCount));
1287
- }
1288
1960
  // async_runtime — heap-free static Promise/microtask runtime. Emitted when
1289
1961
  // the program declares an async function OR references an async-runtime
1290
1962
  // symbol (Async.sleep/.then from a non-async fn). The static path requires
@@ -1298,10 +1970,10 @@ struct __tc_StaticArray {
1298
1970
  domain: 'embedded',
1299
1971
  requiredIncludes: [],
1300
1972
  // Polyfill definitions emit before shimLines, but the runtime's
1301
- // timer bodies call millis() (defined in shimLines) — declare it
1302
- // first so the polyfill compiles even for programs whose source
1973
+ // timer bodies call __tc_now_ms() (defined in shimLines) — declare
1974
+ // it first so the polyfill compiles even for programs whose source
1303
1975
  // has no explicit timing call.
1304
- forwardDeclarations: ['unsigned long millis();'],
1976
+ forwardDeclarations: ['uint32_t __tc_now_ms(void);'],
1305
1977
  helperStructs: [generateStaticAsyncRuntime(8, this.getAsyncRuntimeConfig().waitForPinEdge)],
1306
1978
  helperFunctions: [],
1307
1979
  shimMacros: [],
@@ -1361,19 +2033,10 @@ struct __tc_StaticArray {
1361
2033
  }
1362
2034
  // ── RTOS ─────────────────────────────────────────────────────────────────
1363
2035
  isRtosTarget() {
1364
- // Zephyr is a preemptive RTOS — delay()/k_msleep inside loop() is the
2036
+ // Zephyr is a preemptive RTOS — delay()/k_msleep inside main() is the
1365
2037
  // expected cooperative yield, not an anti-pattern to warn about.
1366
2038
  return true;
1367
2039
  }
1368
- workerSpawnLines(handleId, trampolineName, waiterExpr) {
1369
- return this._workerBacking.spawnLines(handleId, trampolineName, waiterExpr);
1370
- }
1371
- workerSignalDoneExpr(handleId) {
1372
- return this._workerBacking.signalDoneExpr(handleId);
1373
- }
1374
- workerIsDoneExpr(handleId) {
1375
- return this._workerBacking.isDoneExpr(handleId);
1376
- }
1377
2040
  // ── Graphics ──────────────────────────────────────────────────────────────
1378
2041
  // Generic <zephyr/drivers/display.h> + ported GFX primitives (see src/display/).
1379
2042
  // resolveDisplayOp delegates to resolveZephyrDisplayOp with the per-build
@@ -1441,6 +2104,12 @@ struct __tc_StaticArray {
1441
2104
  if (boardId === 'esp32s3_devkitc' || boardId.startsWith('esp32s3')) {
1442
2105
  return 'gdb';
1443
2106
  }
2107
+ // The STM32 Black Pill ships a verified ST-Link probe method in its board
2108
+ // package (openocd runner over SWD, with the reset_config quirk for the
2109
+ // unwired SRST line), so F5 attaches natively out of the box.
2110
+ if (boardId.startsWith('blackpill_')) {
2111
+ return 'gdb';
2112
+ }
1444
2113
  // The plain ESP32 (esp32_devkitc) intentionally stays on 'printf': unlike
1445
2114
  // the S3 it has NO built-in USB-JTAG, so gdb needs an external ESP-PROG
1446
2115
  // probe + a different OpenOCD cfg/toolchain dir (deferred). Falls through.