@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
@@ -0,0 +1,1366 @@
1
+ // ---------------------------------------------------------------------------
2
+ // boardgen.ts — project-local board module generator.
3
+ //
4
+ // One path for every board: joins a board record — from the machine-local
5
+ // catalog (generated from the installed Zephyr SDK's tree) or from a
6
+ // CONTRACT (custom PCB spec) — and emits the two artifacts a project
7
+ // carries instead of a board package:
8
+ //
9
+ // .cuttlefish/board.ts — typed pin/bus/LED/BUTTON exports (the virtual
10
+ // @typecad/board module points here)
11
+ // .cuttlefish/board.json — the BoardConstants flat map (pins.all.*,
12
+ // peripherals.*, zephyr.*) the transpiler's
13
+ // resolveChipFromBoard reconstructs its chip
14
+ // view from
15
+ //
16
+ // All boards are equal: there is no curated descriptor tier. GPIO
17
+ // controller tables are DERIVED from the controller names present in the
18
+ // board's facts per vendor-family port conventions; the datasheet pin
19
+ // sweep enumerates those derived ranges; bus instances and the USB device
20
+ // controller come from the board's own &i2c/&spi/&uart/&usbd nodes.
21
+ // Silicon facts that never appear in devicetree (per-pin ADC channels, PWM
22
+ // matrices) are absent for every board alike.
23
+ //
24
+ // Pure: returns the file contents; the caller (cuttlefish config-loader via
25
+ // the strategy hook, or `cuttlefish board regen`) writes them.
26
+ // ----------------------------------------------------------------------------
27
+ import { parseAsBuiltJson } from './as-built.js';
28
+ import { loadBoardCatalogOverlay, GENERATOR_REV, boardRecordFingerprint, factsFingerprint, findBoardInCatalog, socBusLabelsFromTree, } from '@typecad/cuttlefish/board-catalog';
29
+ /**
30
+ * The catalog lookups resolve against the local overlay — the user's own
31
+ * Zephyr tree (`cuttlefish board sync`, auto-refreshed when the tree
32
+ * moves). There is no compiled-in database: a machine with no tree and no
33
+ * overlay has no boards, and the error below says exactly that.
34
+ */
35
+ function activeBoardData() {
36
+ const data = loadBoardCatalogOverlay()?.data;
37
+ if (!data) {
38
+ throw new Error(`No board catalog on this machine. The catalog is generated from your Zephyr tree —\n` +
39
+ `run 'cuttlefish board sync' (or point CUTTLEFISH_BOARD_CATALOG at a catalog file).`);
40
+ }
41
+ return data;
42
+ }
43
+ /** Look up a board variant by qualified target or bare board id. */
44
+ export function findBoardData(target) {
45
+ return findBoardInCatalog(activeBoardData(), target);
46
+ }
47
+ /** soc name from a qualified target ('board/soc/qual' → 'soc'). */
48
+ export function socOfTarget(target) {
49
+ const parts = target.split('/');
50
+ return parts.length >= 2 ? parts[1] : '';
51
+ }
52
+ /**
53
+ * nRF SAADC AIN→P0-pad map per SoC — [channel, pad] pairs (see the synthesis
54
+ * block in buildModule for the verification trail). Undefined for SoCs
55
+ * without a verified map (including every /cpunet variant: the nRF5340
56
+ * network core declares no SAADC).
57
+ */
58
+ export function nrfSaadcAinPads(soc, identifier) {
59
+ if (identifier.split('/').includes('cpunet'))
60
+ return undefined;
61
+ const table = {
62
+ nrf52832: [[0, 2], [1, 3], [2, 4], [3, 5], [4, 28], [5, 29], [6, 30], [7, 31]],
63
+ nrf52840: [[0, 2], [1, 3], [2, 4], [3, 5], [4, 28], [5, 29], [6, 30], [7, 31]],
64
+ nrf5340: [[0, 4], [1, 5], [2, 6], [3, 7], [4, 25], [5, 26]],
65
+ nrf9160: [[1, 14], [2, 15], [3, 16], [4, 17], [5, 18], [6, 19]],
66
+ nrf9161: [[1, 14], [2, 15], [3, 16], [4, 17], [5, 18], [6, 19]],
67
+ nrf9151: [[1, 14], [2, 15], [3, 16], [4, 17], [5, 18], [6, 19]],
68
+ };
69
+ return table[soc];
70
+ }
71
+ /**
72
+ * ESP32 DAC channel→GPIO pad map per SoC — [channel, pad] pairs, silicon-
73
+ * fixed (no pinctrl group; the DAC outputs through the RTC IO mux on a fixed
74
+ * pad). Only the original ESP32 and the ESP32-S2 carry the 8-bit DAC; the
75
+ * S3/C3/C6/H2 dropped it. Sourced from the HAL's dac_periph.c
76
+ * (`dac_channel_io_num[]`), like the nRF SAADC table is from the PS.
77
+ */
78
+ export function esp32DacPins(soc) {
79
+ const table = {
80
+ esp32: [[0, 25], [1, 26]],
81
+ esp32s2: [[0, 17], [1, 18]],
82
+ };
83
+ return table[soc];
84
+ }
85
+ function identOf(name) {
86
+ return name.replace(/\./g, '_').replace(/[^A-Za-z0-9_]/g, '_');
87
+ }
88
+ // ── Family analog config parameters ─────────────────────────────────────────
89
+ // The per-SoC channel-setup values the driver validates against. These are the
90
+ // "accompanying config parameters" that travel WITH the board's manifest —
91
+ // never per-SoC code in the lowering, because the driver rejects anything
92
+ // else at runtime. Sourced per family; a soc not listed keeps the lowering's
93
+ // default. (The coverage ledger tracks which families are still on defaults.)
94
+ const PWM_CLOCK_HZ = {
95
+ // STM32F411: 100 MHz sysclk, APB1 50×2 = APB2 100 → every timer 100 MHz.
96
+ stm32f411: 100_000_000,
97
+ // STM32F401: all-84 clock tree.
98
+ stm32f401: 84_000_000,
99
+ };
100
+ /** ADC channel-setup gain/reference pair per soc-family prefix. */
101
+ const ADC_CHANNEL_CFG = {
102
+ // STM32's adc driver rejects any gain but ADC_GAIN_1 ("Invalid channel
103
+ // gain", -EINVAL) and maps ADC_REF_INTERNAL to the VREF+ pad.
104
+ stm32: { gain: 'ADC_GAIN_1', reference: 'ADC_REF_INTERNAL' },
105
+ // adc_rpi_pico.c hard-rejects any gain but 1 ("Gain is not valid") — 12-bit
106
+ // against VDD, hence REF_VDD_1 + the 3300 mV vref below.
107
+ rp2040: { gain: 'ADC_GAIN_1', reference: 'ADC_REF_VDD_1' },
108
+ rp2350: { gain: 'ADC_GAIN_1', reference: 'ADC_REF_VDD_1' },
109
+ };
110
+ function namingConvFor(soc) {
111
+ const s = soc.toLowerCase();
112
+ if (/^nrf5|^nrf9/.test(s))
113
+ return 'nrf-port';
114
+ if (/^rp2/.test(s))
115
+ return 'rp-gpio';
116
+ if (/^esp32/.test(s))
117
+ return 'esp32-gpio';
118
+ return 'family';
119
+ }
120
+ /**
121
+ * Controller names that are I2C/SPI GPIO expanders, not the SoC's GPIO.
122
+ * Pins behind them ride the devicetree alias path (LED/BUTTON dtSpecs) and
123
+ * cannot be placed on the HAL pin map — exclude them from the derived
124
+ * controller table and from pin placement.
125
+ */
126
+ function isExpanderController(name) {
127
+ return /^(gpio_exp\d+|sx1509b|mfx|cy8c95xx_port\d+)$/.test(name);
128
+ }
129
+ /**
130
+ * Port-block range for a DTS GPIO controller name, per the vendor family's
131
+ * port convention. Widths are pinned empirically against the board catalog
132
+ * (max observed bit per family): Renesas RA `ioport<N>` ports are 16 bits
133
+ * with letters continuing past ioport9 (a = port 10); Atmel
134
+ * `port<letter>`/`pio<letter>` are 32-bit ports; Cypress `gpio_prt<N>` are
135
+ * 8-bit ports; Ambiq Apollo splits banks into range-encoded `gpio<lo>_<hi>`
136
+ * nodes; bare `gpio<digit>` is the nRF/ESP-style 32-pin controller. The
137
+ * `gpio<letter>` ports are handled by the caller (deriveControllers) —
138
+ * their width is fact-driven, not derivable from the name alone. Returns
139
+ * undefined when the name encodes no derivable range.
140
+ */
141
+ function controllerRangeFor(name) {
142
+ const A = 'a'.charCodeAt(0);
143
+ const letterIndex = (ch) => ch.charCodeAt(0) - A;
144
+ let m = name.match(/^gpio(\d+)_(\d+)$/); // Ambiq gpio0_31, gpio96_127
145
+ if (m)
146
+ return { nodelabel: name, minPin: parseInt(m[1], 10), maxPin: parseInt(m[2], 10) };
147
+ m = name.match(/^gpio([a-z])_(\d+)_(\d+)$/); // SiFli gpioa_00_31, gpioa_32_44
148
+ if (m)
149
+ return { nodelabel: name, minPin: parseInt(m[2], 10), maxPin: parseInt(m[3], 10) };
150
+ m = name.match(/^gpio(\d+)x(\d+)x$/); // NXP KB combined banks gpio0x1x
151
+ if (m) {
152
+ return { nodelabel: name, minPin: parseInt(m[1], 10) * 32, maxPin: parseInt(m[2], 10) * 32 + 31 };
153
+ }
154
+ m = name.match(/^gpio_prt(\d+)$/); // Cypress/Infineon 8-pin ports
155
+ if (m) {
156
+ const base = parseInt(m[1], 10) * 8;
157
+ return { nodelabel: name, minPin: base, maxPin: base + 7 };
158
+ }
159
+ m = name.match(/^ioport(\d+)$/); // Renesas RA 16-pin ports
160
+ if (m) {
161
+ const base = parseInt(m[1], 10) * 16;
162
+ return { nodelabel: name, minPin: base, maxPin: base + 15 };
163
+ }
164
+ m = name.match(/^ioport([a-z])$/); // RA ports past 9 (a = port 10)
165
+ if (m) {
166
+ const base = (10 + letterIndex(m[1])) * 16;
167
+ return { nodelabel: name, minPin: base, maxPin: base + 15 };
168
+ }
169
+ m = name.match(/^(?:port|pio)([a-z])$/); // Atmel SAM 32-pin ports
170
+ if (m) {
171
+ const base = letterIndex(m[1]) * 32;
172
+ return { nodelabel: name, minPin: base, maxPin: base + 31 };
173
+ }
174
+ m = name.match(/^gpio([a-z])_(l|h)$/); // NXP MCX/S32K low/high register banks
175
+ if (m) {
176
+ const base = letterIndex(m[1]) * 32 + (m[2] === 'h' ? 16 : 0);
177
+ return { nodelabel: name, minPin: base, maxPin: base + 15 };
178
+ }
179
+ m = name.match(/^gpio_([a-z])$/); // NXP MCX gpio_a — 32-pin ports
180
+ if (m) {
181
+ const base = letterIndex(m[1]) * 32;
182
+ return { nodelabel: name, minPin: base, maxPin: base + 31 };
183
+ }
184
+ m = name.match(/^gpioa(\d+)$/); // TI CC32xx gpioa0…
185
+ if (m) {
186
+ const base = parseInt(m[1], 10) * 32;
187
+ return { nodelabel: name, minPin: base, maxPin: base + 31 };
188
+ }
189
+ m = name.match(/^mcu_gpio(\d+)$/); // TI K3 MCU domain — offset past the main domain space
190
+ if (m) {
191
+ const base = 256 + parseInt(m[1], 10) * 32;
192
+ return { nodelabel: name, minPin: base, maxPin: base + 31 };
193
+ }
194
+ m = name.match(/^gpio(\d+)_hi$/); // RP2350B high bank (gpio0_hi = pins 32…)
195
+ if (m) {
196
+ const base = (parseInt(m[1], 10) + 1) * 32;
197
+ return { nodelabel: name, minPin: base, maxPin: base + 31 };
198
+ }
199
+ m = name.match(/^(?:main_)?gpio(\d+)$/); // nRF/ESP/RP + TI main domain
200
+ if (m) {
201
+ const base = parseInt(m[1], 10) * 32;
202
+ return { nodelabel: name, minPin: base, maxPin: base + 31 };
203
+ }
204
+ m = name.match(/^hsgpio(\d+)$/); // RW612
205
+ if (m) {
206
+ const base = parseInt(m[1], 10) * 32;
207
+ return { nodelabel: name, minPin: base, maxPin: base + 31 };
208
+ }
209
+ m = name.match(/^psgpio_bank(\d+)$/); // Zynq PS banks
210
+ if (m) {
211
+ const base = parseInt(m[1], 10) * 32;
212
+ return { nodelabel: name, minPin: base, maxPin: base + 31 };
213
+ }
214
+ if (name === 'gpio') {
215
+ // Flat single-controller boards (musca, neorv32, quicklogic).
216
+ return { nodelabel: name, minPin: 0, maxPin: 31 };
217
+ }
218
+ return undefined;
219
+ }
220
+ /**
221
+ * GPIO controller table for a board: derived from the controller names
222
+ * present in the board's facts per vendor-family port conventions. Names
223
+ * that encode no derivable range but are real controllers (the MPS2
224
+ * boards' dedicated gpio_led0/gpio_button FPGAIO IPs, the Pi 5's gio_aon
225
+ * bank) get opaque high bases — collision-free by construction, and
226
+ * LED/BUTTON ride the devicetree alias path anyway. Expanders are excluded
227
+ * (isExpanderController). Returns the table plus the OPAQUE controller
228
+ * names (excluded from the datasheet pin sweep).
229
+ */
230
+ function deriveControllers(entry) {
231
+ // controller → highest bit any fact places on it
232
+ const maxBits = new Map();
233
+ const note = (controller, pin) => {
234
+ if (isExpanderController(controller))
235
+ return;
236
+ maxBits.set(controller, Math.max(maxBits.get(controller) ?? -1, pin));
237
+ };
238
+ if (entry.led)
239
+ note(entry.led.controller, entry.led.pin);
240
+ if (entry.button)
241
+ note(entry.button.controller, entry.button.pin);
242
+ // Silicon routes: the board's own DTS includes its PACKAGE-specific
243
+ // pinctrl file (stm32f411c(c-e)ux-pinctrl.dtsi — the (c-e)ux package), so
244
+ // a harvested route is the board declaring that pad exists — the same
245
+ // board-equal standing as a led fact, not curated data. Without this, a
246
+ // port no led/button/connector happens to sit on exports no PWM/ADC
247
+ // routes (the blackpill lost 12 of 30 PWM routes and PB's ADC channels
248
+ // to exactly this).
249
+ for (const r of [...(entry.pwmPins ?? []), ...(entry.adcPins ?? []), ...(entry.dacPins ?? [])]) {
250
+ note(`gpio${r.port.toLowerCase()}`, r.bit);
251
+ }
252
+ // ESP32 numbered-GPIO family: routes carry global pads (no port letters);
253
+ // the gpio0 convention covers 0-48 on these SoCs.
254
+ for (const r of (entry.espAdc ?? []).map((x) => x.pad).concat(entry.pwmMatrix?.pads ?? [])) {
255
+ note('gpio0', r);
256
+ }
257
+ if (entry.stripLed)
258
+ note(entry.stripLed.controller, entry.stripLed.pin);
259
+ for (const c of entry.extraLeds ?? [])
260
+ note(c.controller, c.pin);
261
+ for (const c of entry.extraButtons ?? [])
262
+ note(c.controller, c.pin);
263
+ for (const conn of entry.connectors ?? []) {
264
+ for (const ref of Object.values(conn.pins))
265
+ note(ref.controller, ref.pin);
266
+ }
267
+ // Overlay io-channel wiring contributes the same standing: the overlay's
268
+ // gpio-map is the board author declaring those pads (Renesas RA boards
269
+ // carry their ENTIRE connector only in board-dir overlays).
270
+ for (const r of entry.connectorAdc ?? [])
271
+ note(r.controller, r.pin);
272
+ // ── SoC gpio-controller inventory (the "every pad" source) ──────────────
273
+ // Seed the table with EVERY controller the SoC dtsi declares, so a port the
274
+ // board's own facts never name still sweeps its full range. The board-fact
275
+ // `note` calls above refine widths (a bit ≥ 16 widens a letter port);
276
+ // `ngpios`, when the dtsi states one, provides the baseline width for
277
+ // families whose port convention isn't derivable from the name alone.
278
+ for (const c of entry.gpioControllers ?? []) {
279
+ if (isExpanderController(c.nodelabel))
280
+ continue;
281
+ if (maxBits.has(c.nodelabel))
282
+ continue;
283
+ maxBits.set(c.nodelabel, c.ngpios !== undefined ? c.ngpios - 1 : 0);
284
+ }
285
+ const derived = [];
286
+ const opaque = new Set();
287
+ // Letter-port family (gpioa…gpiop): STM32-class ports are 16 bits wide,
288
+ // but several vendors reuse the same controller names with 32-bit ports
289
+ // (TI MSPM0 gpiob.22, NXP Kinetis gpioe.31, OpenISA RV32M1 gpioa.31). A
290
+ // hard-coded 16-wide range placed those bits outside every controller
291
+ // range, and the raw-GPIO lowering then fell back to a nonexistent
292
+ // `gpio0` nodelabel (undefined `__device_dts_ord_…` at C++ compile time).
293
+ // Size each port 32 bits when an observed fact uses bit ≥ 16, and hand
294
+ // out bases sequentially so a widened port never overlaps its neighbours
295
+ // (boards whose facts all sit at bits ≤ 15 keep the 16-wide letterIndex
296
+ // numbering; contiguous-from-a boards reproduce it exactly).
297
+ let letterBase = 0;
298
+ for (const name of [...maxBits.keys()].filter((n) => /^gpio[a-p]$/.test(n)).sort()) {
299
+ const width = (maxBits.get(name) ?? 0) >= 16 ? 32 : 16;
300
+ derived.push({ nodelabel: name, minPin: letterBase, maxPin: letterBase + width - 1 });
301
+ letterBase += width;
302
+ }
303
+ for (const name of maxBits.keys()) {
304
+ if (/^gpio[a-p]$/.test(name))
305
+ continue;
306
+ const range = controllerRangeFor(name);
307
+ if (range)
308
+ derived.push(range);
309
+ else
310
+ opaque.add(name);
311
+ }
312
+ // Opaque controllers: deterministic sequential slots far above any
313
+ // derived range so HAL pin numbers never collide.
314
+ [...opaque].sort().forEach((name, i) => {
315
+ derived.push({ nodelabel: name, minPin: 4096 + i * 32, maxPin: 4096 + i * 32 + 31 });
316
+ });
317
+ derived.sort((a, b) => a.minPin - b.minPin);
318
+ return { controllers: derived, opaque };
319
+ }
320
+ /**
321
+ * Pin name + HAL number for a (controller, bit) fact, consistent with the
322
+ * derived controller table's numbering. Family-aware per the board's
323
+ * naming convention: ioport1.3 → P103, gpio0_31.5 → GPIO5, porta.23 →
324
+ * PA23, gpio_prt5.6 → P5_6, gpio0.26 → GPIO26 (or P0.26 on nRF / GP26 on
325
+ * RP2040 per the soc's convention). Undefined when the controller is an
326
+ * expander or encodes no range and has no opaque slot.
327
+ */
328
+ function placePin(conv, ref, table) {
329
+ if (isExpanderController(ref.controller))
330
+ return undefined;
331
+ const ctrl = table.find((c) => c.nodelabel === ref.controller);
332
+ if (!ctrl)
333
+ return undefined;
334
+ const placed = (name) => ({ name, ident: identOf(name), halPin: ctrl.minPin + ref.pin });
335
+ // Range-encoded families (Ambiq gpio0_31, SiFli gpioa_00_31): the bit is
336
+ // the global pin offset by the range base.
337
+ if (/^gpio[a-z]?_\d+_\d+$/.test(ref.controller))
338
+ return placed(`GPIO${ctrl.minPin + ref.pin}`);
339
+ if (ref.controller === 'gpio')
340
+ return placed(`GPIO${ref.pin}`);
341
+ let m = ref.controller.match(/^ioport([0-9]|[a-z])$/);
342
+ if (m) {
343
+ const port = /[0-9]/.test(m[1]) ? m[1] : String(10 + m[1].charCodeAt(0) - 'a'.charCodeAt(0));
344
+ return placed(`P${port}${String(ref.pin).padStart(2, '0')}`);
345
+ }
346
+ // Letter-port families (Atmel porta/pioa, NXP gpio_a, STM32 gpioc) and
347
+ // the MCX/S32K low/high register banks (gpioc_h.0 → PC16).
348
+ m = ref.controller.match(/^(?:port|pio)([a-z])$/);
349
+ if (m)
350
+ return placed(`P${m[1].toUpperCase()}${ref.pin}`);
351
+ m = ref.controller.match(/^gpio_?([a-p])$/);
352
+ if (m)
353
+ return placed(`P${m[1].toUpperCase()}${ref.pin}`);
354
+ m = ref.controller.match(/^gpio([a-z])_(l|h)$/);
355
+ if (m)
356
+ return placed(`P${m[1].toUpperCase()}${(m[2] === 'h' ? 16 : 0) + ref.pin}`);
357
+ m = ref.controller.match(/^gpio_prt(\d+)$/);
358
+ if (m)
359
+ return placed(`P${m[1]}_${ref.pin}`);
360
+ m = ref.controller.match(/^gpioa(\d+)$/);
361
+ if (m)
362
+ return placed(`PA${m[1]}${ref.pin}`);
363
+ // Digit controllers (gpio0, gpio0_hi, main_gpio0, gpio0x1x, hsgpio…):
364
+ // the HAL number is the global pin (base + bit), named per the soc's
365
+ // convention — nRF P0.05, RP2040 GP5, Espressif/everything else GPIO5.
366
+ if (/gpio\d/.test(ref.controller)) {
367
+ if (conv === 'nrf-port') {
368
+ const port = (ctrl.minPin / 32) | 0;
369
+ return placed(`P${port}.${String(ref.pin).padStart(2, '0')}`);
370
+ }
371
+ if (conv === 'rp-gpio')
372
+ return placed(`GP${ctrl.minPin + ref.pin}`);
373
+ return placed(`GPIO${ctrl.minPin + ref.pin}`);
374
+ }
375
+ // Opaque controllers (gpio_led0, gio_aon, …): generic label-based name.
376
+ const letters = ref.controller.replace(/[^a-z]/gi, '').toUpperCase().replace('GPIO', '');
377
+ const name = letters.length > 0 ? `P${letters}${ref.pin}` : `${ref.controller.toUpperCase()}${ref.pin}`;
378
+ return placed(name);
379
+ }
380
+ /**
381
+ * Parse a datasheet pin name into (controller nodelabel, controller-relative
382
+ * bit) per the soc's naming family — the inverse of the sweep naming:
383
+ * nRF P0.28 / P0_28 → gpio0.28
384
+ * RP2040 GP25 → gpio0.25
385
+ * ESP32 GPIO9 → gpio0.9 (GPIO32+ → gpio1.x)
386
+ * letters PA5 / PB6 → gpioa.5 / gpiob.6
387
+ * flat GPIO9 (non-esp32 socs, single flat controller) → gpio.9
388
+ */
389
+ export function parsePinName(soc, name) {
390
+ const conv = namingConvFor(soc);
391
+ const n = name.trim().toUpperCase();
392
+ let m = n.match(/^P(\d)\.(\d{1,2})$/);
393
+ if (conv === 'nrf-port' && m)
394
+ return { controller: `gpio${m[1]}`, pin: Number(m[2]) };
395
+ m = n.match(/^GP(\d{1,2})$/);
396
+ if (conv === 'rp-gpio' && m)
397
+ return { controller: 'gpio0', pin: Number(m[1]) };
398
+ m = n.match(/^GPIO(\d{1,2})$/);
399
+ if (m) {
400
+ const num = Number(m[1]);
401
+ if (conv === 'esp32-gpio')
402
+ return { controller: num < 32 ? 'gpio0' : 'gpio1', pin: num < 32 ? num : num - 32 };
403
+ return { controller: 'gpio', pin: num };
404
+ }
405
+ m = n.match(/^P([A-Pa-p])(\d{1,2})$/);
406
+ if (m)
407
+ return { controller: `gpio${m[1].toLowerCase()}`, pin: Number(m[2]) };
408
+ return undefined;
409
+ }
410
+ /** Parse + shape-validate the facts file; a clear error names the file. */
411
+ export function parseUserFactsJson(text, source = 'cuttlefish.facts.json') {
412
+ let parsed;
413
+ try {
414
+ parsed = JSON.parse(text);
415
+ }
416
+ catch (err) {
417
+ throw new Error(`${source} is not valid JSON: ${err.message}`);
418
+ }
419
+ const file = parsed;
420
+ if (!file || typeof file !== 'object' || !file.boards || typeof file.boards !== 'object') {
421
+ throw new Error(`${source} must carry a top-level "boards" object keyed by board target.`);
422
+ }
423
+ for (const [key, facts] of Object.entries(file.boards)) {
424
+ if (facts.adc && !Array.isArray(facts.adc.channels)) {
425
+ throw new Error(`${source}: boards['${key}'].adc.channels must be an array.`);
426
+ }
427
+ if (facts.pwm && !Array.isArray(facts.pwm.specs)) {
428
+ throw new Error(`${source}: boards['${key}'].pwm.specs must be an array.`);
429
+ }
430
+ if (facts.dac && (!facts.dac.device || !Array.isArray(facts.dac.channels))) {
431
+ throw new Error(`${source}: boards['${key}'].dac needs a device and a channels array.`);
432
+ }
433
+ }
434
+ return file;
435
+ }
436
+ /** The section matching a target — the same exact → lowercase → prefix
437
+ * leniency findBoardData applies to catalog keys. */
438
+ export function userFactsForTarget(file, target) {
439
+ if (!file)
440
+ return undefined;
441
+ const keys = Object.keys(file.boards);
442
+ const hit = keys.find((k) => k === target)
443
+ ?? keys.find((k) => k.toLowerCase() === target.toLowerCase())
444
+ ?? keys.find((k) => target.startsWith(k) || k.startsWith(target));
445
+ return hit ? file.boards[hit] : undefined;
446
+ }
447
+ export function buildModule(entry, userFacts, factsSuffix = '', seedWarnings = []) {
448
+ const soc = socOfTarget(entry.identifier);
449
+ const conv = namingConvFor(soc);
450
+ // ── Controller table + datasheet sweep (the same for every board) ──────
451
+ const { controllers, opaque } = deriveControllers(entry);
452
+ // ── Silicon PWM/analog routes (SoC pinctrl harvest) ────────────────────
453
+ // Raw port/bit routes from the catalog (STM32 vendor-HAL pinctrl dtsi;
454
+ // matrix/arithmetic families — ESP32 LEDC, RP2040 slices — ship no
455
+ // pwmPins and remain family conventions). A route survives only when its
456
+ // pad's controller is in the derived table: a port the board's facts
457
+ // never reference is not exported, silicon route or not — all boards
458
+ // equal. First timer per pad wins (lowest timer number, then channel).
459
+ const globalForPort = (port, bit) => {
460
+ // Letter-port controller naming varies by vendor: STM32/sam0 styles are
461
+ // gpio<letter> (gpioa), Atmel SAM's is port<letter> (porta) — both are
462
+ // the same letter-port convention, matched by whatever the derived
463
+ // table actually contains.
464
+ const lower = port.toLowerCase();
465
+ const ctrl = controllers.find((c) => c.nodelabel === `gpio${lower}` || c.nodelabel === `port${lower}`);
466
+ // The harvested bit is PORT-RELATIVE (PB6 → bit 6), so the guard is the
467
+ // controller's width, not its global range — a `bit >= minPin` check
468
+ // silently dropped every route on any port after the first.
469
+ return ctrl && bit >= 0 && bit <= ctrl.maxPin - ctrl.minPin ? ctrl.minPin + bit : undefined;
470
+ };
471
+ const siliconPwm = [];
472
+ const pwmSeenPad = new Set();
473
+ for (const p of [...(entry.pwmPins ?? [])].sort((a, b) => a.source.localeCompare(b.source, undefined, { numeric: true }) || a.channel - b.channel)) {
474
+ // The route's controller: STM32 spells the pinctrl source `tim{N}` with
475
+ // the DT convention mapping onto the `pwm{N}` child under `timers{N}`;
476
+ // every other family harvested so far (Kinetis `ftmN`, LPC `ctimerN`,
477
+ // GD32 `timerN`, i.MX `flexpwmN_pwmK`) uses the DT controller label
478
+ // VERBATIM as the source.
479
+ const timer = p.source.match(/^tim(\d+)$/);
480
+ const controller = timer ? `pwm${timer[1]}` : p.source;
481
+ if (!controller)
482
+ continue;
483
+ const pin = globalForPort(p.port, p.bit);
484
+ if (pin === undefined || pwmSeenPad.has(pin))
485
+ continue;
486
+ pwmSeenPad.add(pin);
487
+ siliconPwm.push({ pin, controller, channel: p.channel, pinctrl: p.pinctrl });
488
+ }
489
+ // Cross-check: only routes whose source DEVICE the SoC declares (a
490
+ // pinctrl file can carry routes for peripherals the dtsi never defines).
491
+ // EVERY declared controller contributes its routes — STM32 adc1/adc2 (and
492
+ // adc3 on bigger parts) share channel indices per controller, so each
493
+ // route carries its owning controller. The primary (first sorted) source
494
+ // is the descriptor's nodeLabel; its channels omit the controller field
495
+ // so a single-controller manifest is unchanged.
496
+ const analogDevices = new Set(entry.analogDevices ?? []);
497
+ // Digitless single-controller pinctrl spelling (STM32 f0/l0/l1/wl write
498
+ // `adc_in0_pa0`) maps onto the device label the SoC dtsi actually
499
+ // declares (`adc1`), so the cross-check and the nodeLabel stay honest.
500
+ const adcDeviceLabels = [...analogDevices].filter((d) => /^adc/.test(d)).sort();
501
+ const adcRoutes = (entry.adcPins ?? []).map((a) => a.source === 'adc' && adcDeviceLabels.length > 0 ? { ...a, source: adcDeviceLabels[0] } : a);
502
+ const adcSources = [...new Set(adcRoutes.map((a) => a.source))]
503
+ .filter((src) => analogDevices.size === 0 || analogDevices.has(src)).sort();
504
+ const siliconAdc = [];
505
+ const adcSeenPad = new Set();
506
+ for (const a of adcRoutes
507
+ .filter((x) => adcSources.includes(x.source))
508
+ .sort((x, y) => x.source.localeCompare(y.source, undefined, { numeric: true }) || x.channel - y.channel)) {
509
+ const pin = globalForPort(a.port, a.bit);
510
+ if (pin === undefined || adcSeenPad.has(pin))
511
+ continue;
512
+ adcSeenPad.add(pin);
513
+ siliconAdc.push({
514
+ pin,
515
+ channel: a.channel,
516
+ ...(a.source !== adcSources[0] ? { controller: a.source } : {}),
517
+ ...(a.pinctrl ? { pinctrl: a.pinctrl } : {}),
518
+ });
519
+ }
520
+ // ESP32 SARADC routes (pad form, no pinctrl groups — the pads are analog
521
+ // by silicon). Same every-declared-unit rule: each unit's routes carry the
522
+ // unit as their controller; the first unit stays the implicit primary.
523
+ const espAdcUnits = [...new Set((entry.espAdc ?? []).map((r) => r.source))]
524
+ .filter((src) => analogDevices.size === 0 || analogDevices.has(src)).sort();
525
+ const espAdcUnit = espAdcUnits[0];
526
+ for (const r of [...(entry.espAdc ?? [])]
527
+ .filter((x) => espAdcUnits.includes(x.source))
528
+ .sort((x, y) => x.source.localeCompare(y.source, undefined, { numeric: true }) || x.channel - y.channel)) {
529
+ const ctrl = controllers.find((c) => c.nodelabel === 'gpio0');
530
+ if (!ctrl || r.pad > ctrl.maxPin - ctrl.minPin)
531
+ continue;
532
+ siliconAdc.push({
533
+ pin: ctrl.minPin + r.pad,
534
+ channel: r.channel,
535
+ ...(r.source !== espAdcUnit ? { controller: r.source } : {}),
536
+ });
537
+ }
538
+ // nRF SAADC: Nordic silicon has no per-pad ADC devicetree — the AIN
539
+ // index IS the driver channel and its pad is silicon-fixed (the XIAO's
540
+ // board DTS carries no ADC node at all). Family tables, same shape as
541
+ // PWM_CLOCK_HZ, all verified against the in-tree DK io-channel-map
542
+ // comments. Entries with no agreeing in-tree source are OMITTED, not
543
+ // guessed — a missing channel just leaves that pad non-analog in the
544
+ // facts:
545
+ // nrf52832/nrf52840: AIN0-7 = P0.02-P0.05, P0.28-P0.31 (nrf52840dk,
546
+ // nrf52dk; AIN0/AIN3 complete the P0.02-P0.05 block per the PS).
547
+ // nrf5340: AIN0-5 = P0.04-P0.07, P0.25-P0.26 (nrf5340dk, nrf7002dk).
548
+ // AIN6/AIN7 pads have no agreeing source — the audio DK's comments
549
+ // conflict with both DKs — omitted until verified. App core only:
550
+ // the netcore dtsi declares no SAADC.
551
+ // nrf9160/nrf9161/nrf9151: AIN1-6 = P0.14-P0.19 (nrf9160dk, nrf9161dk,
552
+ // nrf9151dk — three independent boards agree). AIN0/AIN7 pads are
553
+ // undocumented in-tree — omitted.
554
+ // nRF54L declares no nordic,nrf-saadc node (VND SAADC) — a separate
555
+ // driver/channel shape, follow-up.
556
+ const nrfSaadc = nrfSaadcAinPads(soc, entry.identifier);
557
+ let nrfSaadcSynth = false;
558
+ if (siliconAdc.length === 0 && espAdcUnits.length === 0 && nrfSaadc) {
559
+ const gpio0 = controllers.find((c) => c.nodelabel === 'gpio0');
560
+ for (const [channel, pad] of nrfSaadc) {
561
+ if (gpio0 && pad > gpio0.maxPin - gpio0.minPin)
562
+ continue;
563
+ siliconAdc.push({ pin: (gpio0?.minPin ?? 0) + pad, channel });
564
+ }
565
+ nrfSaadcSynth = siliconAdc.length > 0;
566
+ if (nrfSaadcSynth)
567
+ adcSources.push('adc');
568
+ }
569
+ // RP2 header-matrix routes (rpi-pico-*-pinctrl.h macros): GLOBAL pad
570
+ // numbers (the macro's P<n>), pinmux-macro tokens for the overlay's
571
+ // pinctrl groups (both the ADC and PWM drivers apply pinctrl on RP2). A
572
+ // pad may live on ANY controller — the RP2350B's ADC macros sit on pads
573
+ // 40-43 (gpio1 territory), so a gpio0-only check silently dropped them.
574
+ const padToPin = (pad) => controllers.find((c) => pad >= c.minPin && pad <= c.maxPin) ? pad : undefined;
575
+ for (const r of entry.padAdc ?? []) {
576
+ const pin = padToPin(r.pad);
577
+ if (pin === undefined)
578
+ continue;
579
+ if (adcSeenPad.has(pin))
580
+ continue;
581
+ adcSeenPad.add(pin);
582
+ siliconAdc.push({ pin, channel: r.channel, pinctrl: r.pinctrl });
583
+ if (!adcSources.includes(r.source))
584
+ adcSources.push(r.source);
585
+ }
586
+ for (const r of entry.padPwm ?? []) {
587
+ const pin = padToPin(r.pad);
588
+ if (pin === undefined)
589
+ continue;
590
+ if (pwmSeenPad.has(pin))
591
+ continue;
592
+ pwmSeenPad.add(pin);
593
+ siliconPwm.push({ pin, controller: r.source, channel: r.channel, pinctrl: r.pinctrl });
594
+ }
595
+ // Connector io-channel wiring (the DKs' A0-A5 ↔ AIN maps): the last
596
+ // fallback for boards on SoCs without a family table or a harvested
597
+ // route — the channels the BOARD actually wires, by its own authorship.
598
+ if (siliconAdc.length === 0 && (entry.connectorAdc ?? []).length > 0) {
599
+ for (const r of entry.connectorAdc ?? []) {
600
+ const ctrl = controllers.find((c) => c.nodelabel === r.controller);
601
+ if (!ctrl || r.pin > ctrl.maxPin - ctrl.minPin)
602
+ continue;
603
+ const pin = ctrl.minPin + r.pin;
604
+ if (adcSeenPad.has(pin))
605
+ continue;
606
+ adcSeenPad.add(pin);
607
+ siliconAdc.push({ pin, channel: r.channel });
608
+ if (!adcSources.includes(r.source))
609
+ adcSources.push(r.source);
610
+ }
611
+ }
612
+ // User facts (cuttlefish.facts.json) — the escape hatch. User routes win
613
+ // PER PIN over every harvested source above, and each takeover is warned.
614
+ // Seeded with the pinctrl harvest's own lint results (name↔value
615
+ // disagreements — routes dropped as untrustworthy upstream of here).
616
+ const userWarnings = [...(entry.pinctrlWarnings ?? []), ...seedWarnings];
617
+ // pwm specs: run after ALL pwm sources (STM32 harvest, RP2 header
618
+ // matrices) so every shadow — any family — lands in the warnings. Any
619
+ // user pwm spec also suppresses the auto nRF matrix below (the
620
+ // siliconPwm gate): the user owns pwm on that board.
621
+ if (userFacts?.pwm) {
622
+ for (const u of userFacts.pwm.specs) {
623
+ const shadowed = siliconPwm.findIndex((x) => x.pin === u.pin);
624
+ if (shadowed >= 0) {
625
+ const old = siliconPwm[shadowed];
626
+ userWarnings.push(`pwm pin ${u.pin}: user spec (${u.controller} ch ${u.channel}) shadows the harvested route (${old.controller} ch ${old.channel}).`);
627
+ siliconPwm.splice(shadowed, 1);
628
+ }
629
+ pwmSeenPad.add(u.pin);
630
+ siliconPwm.push({ pin: u.pin, controller: u.controller, channel: u.channel, ...(u.pinctrl ? { pinctrl: u.pinctrl } : {}) });
631
+ }
632
+ }
633
+ // adc channels: same rule across pinctrl harvest, headers, family tables,
634
+ // connector wiring. The declared device (or the existing primary, or
635
+ // 'adc') joins adcSources so the nodeLabel and the init blocks stay
636
+ // consistent; on a board with NO harvested adc at all the user's device
637
+ // IS the primary.
638
+ if (userFacts?.adc) {
639
+ const device = userFacts.adc.device ?? adcSources[0] ?? 'adc';
640
+ if (!adcSources.includes(device))
641
+ adcSources.push(device);
642
+ for (const u of userFacts.adc.channels) {
643
+ const shadowed = siliconAdc.findIndex((x) => x.pin === u.pin);
644
+ if (shadowed >= 0) {
645
+ const old = siliconAdc[shadowed];
646
+ userWarnings.push(`adc pin ${u.pin}: user channel ${u.channel} shadows the harvested channel ${old.channel}.`);
647
+ siliconAdc.splice(shadowed, 1);
648
+ }
649
+ adcSeenPad.add(u.pin);
650
+ siliconAdc.push({
651
+ pin: u.pin,
652
+ channel: u.channel,
653
+ ...(device !== adcSources[0] ? { controller: device } : {}),
654
+ ...(u.pinctrl ? { pinctrl: u.pinctrl } : {}),
655
+ });
656
+ }
657
+ }
658
+ const dacSources = [...new Set((entry.dacPins ?? []).map((d) => d.source))]
659
+ .filter((src) => analogDevices.has(src)).sort();
660
+ const siliconDac = [];
661
+ for (const d of [...(entry.dacPins ?? [])]
662
+ .filter((x) => x.source === dacSources[0])
663
+ .sort((x, y) => x.channel - y.channel)) {
664
+ const pin = globalForPort(d.port, d.bit);
665
+ if (pin === undefined)
666
+ continue;
667
+ siliconDac.push({ pin, channel: d.channel, pinctrl: d.pinctrl });
668
+ }
669
+ // ESP32 DAC: silicon-fixed channel→pad (no pinctrl group, no harvested
670
+ // route). Only when no pinctrl DAC was harvested and the soc has the DAC.
671
+ const esp32Dac = esp32DacPins(soc);
672
+ if (siliconDac.length === 0 && esp32Dac) {
673
+ const gpio0 = controllers.find((c) => c.nodelabel === 'gpio0');
674
+ for (const [channel, pad] of esp32Dac) {
675
+ if (gpio0 && pad > gpio0.maxPin - gpio0.minPin)
676
+ continue;
677
+ siliconDac.push({ pin: (gpio0?.minPin ?? 0) + pad, channel, pinctrl: '' });
678
+ }
679
+ if (siliconDac.length > 0 && !dacSources.includes('dac'))
680
+ dacSources.push('dac');
681
+ }
682
+ // User facts (cuttlefish.facts.json): dac channels win per pin, and the
683
+ // declared device joins the sources (no analogDevices cross-check — the
684
+ // user vouches for it).
685
+ if (userFacts?.dac) {
686
+ if (!dacSources.includes(userFacts.dac.device))
687
+ dacSources.push(userFacts.dac.device);
688
+ for (const u of userFacts.dac.channels) {
689
+ const shadowed = siliconDac.findIndex((x) => x.pin === u.pin);
690
+ if (shadowed >= 0) {
691
+ userWarnings.push(`dac pin ${u.pin}: user channel ${u.channel} shadows the harvested channel ${siliconDac[shadowed].channel}.`);
692
+ siliconDac.splice(shadowed, 1);
693
+ }
694
+ siliconDac.push({ pin: u.pin, channel: u.channel, pinctrl: '' });
695
+ }
696
+ }
697
+ const siliconPwmPins = new Set(siliconPwm.map((s) => s.pin));
698
+ const siliconAdcPins = new Set(siliconAdc.map((s) => s.pin));
699
+ const siliconDacPins = new Set(siliconDac.map((s) => s.pin));
700
+ // ── Hardware counters (Zephyr counter devices) ─────────────────────────
701
+ // Kernel-claim exclusions — counter nodes the system tick owns are NOT
702
+ // free; a family table, like the letter-port conventions: the ESP32
703
+ // esp_timer (espressif,esp32-rtc-timer) always belongs to the kernel, and
704
+ // nRF's RTC0 is the default system clock (RTC1+ are free). STM32 has no
705
+ // exclusion — Cortex-M SysTick is the tick, so the RTC is free.
706
+ const KERNEL_CLAIMED = [
707
+ { compatible: 'espressif,esp32-rtc-timer' },
708
+ { compatible: 'nordic,nrf-rtc', nodeLabel: 'rtc0' },
709
+ ];
710
+ const isKernelClaimed = (n) => KERNEL_CLAIMED.some((k) => (k.compatible === undefined || k.compatible === n.compatible)
711
+ && (k.nodeLabel === undefined || k.nodeLabel === n.nodeLabel));
712
+ const hwtimerControllers = (entry.counterNodes ?? []).filter((n) => !isKernelClaimed(n));
713
+ const pins = [];
714
+ const byIdent = new Map();
715
+ const pushPin = (p) => {
716
+ if (!p)
717
+ return undefined;
718
+ const existing = byIdent.get(p.ident);
719
+ if (existing)
720
+ return existing;
721
+ byIdent.set(p.ident, p);
722
+ pins.push(p);
723
+ return p;
724
+ };
725
+ // Datasheet sweep: enumerate every derived (non-opaque) controller
726
+ // range. Opaque controllers (FPGAIO IPs, AON banks) carry no sweepable
727
+ // silicon layout — their facts still place through placePin below.
728
+ for (const ctrl of controllers) {
729
+ if (opaque.has(ctrl.nodelabel))
730
+ continue;
731
+ const span = ctrl.maxPin - ctrl.minPin + 1;
732
+ for (let bit = 0; bit < span; bit++)
733
+ pushPin(placePin(conv, { controller: ctrl.nodelabel, pin: bit }, controllers));
734
+ }
735
+ // Connector pins from the record (labels like D0/D10, or contract pads).
736
+ // A bit beyond the controller's declared width (the m5stack_fire DTS
737
+ // wires grove p1 to gpio1.21 — a pad classic ESP32 does not have) is
738
+ // unplaceable: skip it rather than mint a pin no lowering can route.
739
+ const connectorExports = [];
740
+ for (const conn of entry.connectors ?? []) {
741
+ for (const [label, ref] of Object.entries(conn.pins)) {
742
+ const ctrl = controllers.find((c) => c.nodelabel === ref.controller);
743
+ if (ctrl && ref.pin <= ctrl.maxPin - ctrl.minPin) {
744
+ const placed = pushPin(placePin(conv, ref, controllers));
745
+ if (placed)
746
+ connectorExports.push({ label, pin: placed });
747
+ }
748
+ }
749
+ }
750
+ // LED / BUTTON from the record's DTS facts. A board fact is real even
751
+ // when the pad is outside the sweep's family layout — construct it
752
+ // directly so the export exists; the dtSpec path addresses it.
753
+ const placeDts = (ref) => pushPin(placePin(conv, ref, controllers));
754
+ const ledPin = entry.led ? placeDts(entry.led) : undefined;
755
+ // LED fallback when no gpio-leds node: the record's addressable-strip
756
+ // fact (worldsemi,ws2812 — a plain-GPIO LED, no led0 spec).
757
+ const ledPinFinal = ledPin
758
+ ?? (entry.stripLed ? placeDts(entry.stripLed) : undefined);
759
+ const buttonPin = entry.button ? placeDts(entry.button) : undefined;
760
+ const extraLedPins = (entry.extraLeds ?? []).map((l) => placeDts(l)).filter((p) => p !== undefined);
761
+ const extraButtonPins = (entry.extraButtons ?? []).map((b) => placeDts(b)).filter((p) => p !== undefined);
762
+ // ── board.ts ────────────────────────────────────────────────────────────
763
+ const hasUsb = entry.usbDevice === 'enabled';
764
+ const wdtNodeLabel = entry.wdtNodeLabel;
765
+ // Aliased pwm-leds (DT_ALIAS-addressable) — the board's own PWM LED
766
+ // channels; specs ride virtual pins from 8192 (above every real range and
767
+ // the opaque controller blocks).
768
+ const pwmLedSpecs = (entry.pwmLeds ?? []).filter((l) => l.alias);
769
+ const buses = entry.buses ?? { i2c: [], spi: [], uart: [] };
770
+ // nRF PWM: any GPIO pad can carry any channel of a PWM peripheral (psel
771
+ // routing — pinctrl psel order IS channel order), so it is a matrix like
772
+ // the ESP32's LEDC: channels are assigned to the driven pads at overlay
773
+ // time. The first declared peripheral (pwm0) is exposed: four concurrent
774
+ // channels, and exceeding them is an explicit overlay error even though
775
+ // further peripherals idle — a documented v1 ceiling.
776
+ const pwmControllers = [...new Set(entry.pwmNodes ?? [])].sort();
777
+ // pwm-leds specs (virtual pins 8192+) coexist with the matrix — a board's
778
+ // own PWM LED channel plus any-pad matrix channels are different pins.
779
+ const nrfPwmMatrix = entry.pwmMatrix || siliconPwm.length > 0 || !/^nrf/.test(soc) || pwmControllers.length === 0
780
+ ? undefined
781
+ : (() => {
782
+ const gpio0 = controllers.find((c) => c.nodelabel === 'gpio0');
783
+ const gpio1 = controllers.find((c) => c.nodelabel === 'gpio1');
784
+ if (!gpio0)
785
+ return undefined;
786
+ const pads = [];
787
+ for (let p = gpio0.minPin; p <= gpio0.maxPin; p++)
788
+ pads.push(p);
789
+ if (gpio1)
790
+ for (let p = gpio1.minPin; p <= gpio1.maxPin; p++)
791
+ pads.push(p);
792
+ return { controller: pwmControllers[0], channelCount: 4, pads };
793
+ })();
794
+ const pwmMatrix = entry.pwmMatrix ?? nrfPwmMatrix;
795
+ // Export-name reservation: the generated module must not redeclare. The
796
+ // connector maps reuse labels across headers (the EK-RA8M1's two grove
797
+ // headers both carry IO6/IO7/p7, wired to different pads), and a label
798
+ // can also collide with a datasheet pin ident or a core export (LED,
799
+ // I2C0, …). First connector label wins; later ones keep their
800
+ // datasheet-named pin — always exported above — and the manifest's
801
+ // pins.aliases follow the same rule so module and manifest agree.
802
+ const reservedNames = new Set([
803
+ ...pins.map((p) => p.ident),
804
+ 'LED',
805
+ 'BUTTON',
806
+ // '@typecad/hal' imports — a connector silkscreen label can literally be
807
+ // 'Pin' (phyBOARD-Atlas), and `export const Pin` merges with the import.
808
+ 'Pin',
809
+ 'I2CBus',
810
+ 'SPIBus',
811
+ 'UART',
812
+ 'USBConsole',
813
+ 'PWM',
814
+ ]);
815
+ buses.i2c.forEach((_, i) => reservedNames.add(`I2C${i}`));
816
+ buses.spi.forEach((_, i) => reservedNames.add(`SPI${i}`));
817
+ buses.uart.forEach((_, i) => reservedNames.add(`UART${i}`));
818
+ if (hasUsb)
819
+ reservedNames.add('USB0');
820
+ pwmLedSpecs.forEach((_, i) => reservedNames.add(i === 0 ? 'PWMLED' : `PWMLED${i}`));
821
+ const connectorLabelOwner = new Map();
822
+ for (const { label, pin } of connectorExports) {
823
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(label))
824
+ continue;
825
+ if (reservedNames.has(label) || connectorLabelOwner.has(label))
826
+ continue;
827
+ connectorLabelOwner.set(label, pin);
828
+ }
829
+ const ts = [];
830
+ ts.push(`// GENERATED by cuttlefish boardgen from the Zephyr board catalog —`);
831
+ ts.push(`// ${entry.identifier} (${entry.name}, ${entry.vendor}).`);
832
+ ts.push(`// Regenerate with: npx cuttlefish board regen`);
833
+ ts.push('');
834
+ ts.push(`import { Pin, I2CBus, SPIBus, UART${hasUsb ? ', USBConsole' : ''}${pwmLedSpecs.length > 0 ? ', PWM' : ''} } from '@typecad/hal';`);
835
+ ts.push('');
836
+ // ── Hardware-class gateway ─────────────────────────────────────────────
837
+ // The board module is the NARROWED surface: every hardware class re-export
838
+ // here exists only when this board's facts support it, so importing
839
+ // unavailable hardware fails at module resolution (editor + transpile)
840
+ // instead of at a deep diagnostic. '@typecad/hal' stays the implementation
841
+ // package; user code imports hardware from '@typecad/board'.
842
+ ts.push('// Hardware this board actually has — unavailable hardware is not importable.');
843
+ ts.push(`export { GPIO, Thread, Time, Sensor } from '@typecad/hal';`);
844
+ if (wdtNodeLabel)
845
+ ts.push(`export { Watchdog } from '@typecad/hal';`);
846
+ if (siliconPwm.length > 0 || pwmLedSpecs.length > 0 || pwmMatrix)
847
+ ts.push(`export { PWM } from '@typecad/hal';`);
848
+ if (siliconAdc.length > 0)
849
+ ts.push(`export { ADC } from '@typecad/hal';`);
850
+ if (siliconDac.length > 0)
851
+ ts.push(`export { DAC } from '@typecad/hal';`);
852
+ if (buses.i2c.length > 0)
853
+ ts.push(`export { I2CTarget } from '@typecad/hal';`);
854
+ if (buses.spi.length > 0)
855
+ ts.push(`export { SPITarget } from '@typecad/hal';`);
856
+ if (buses.uart.length > 0)
857
+ ts.push(`export { UART } from '@typecad/hal';`);
858
+ if (hwtimerControllers.length > 0)
859
+ ts.push(`export { Counter } from '@typecad/hal';`);
860
+ if (hasUsb)
861
+ ts.push(`export { USBConsole } from '@typecad/hal';`);
862
+ // Store/File: a persisted backend needs a storage region — either the
863
+ // board's own storage_partition (harvested reg) or a synthesizable one
864
+ // (flash size known, no existing partition to collide with).
865
+ if (entry.storageReg || (entry.flashKb && !entry.hasStoragePartition)) {
866
+ ts.push(`export { Store, File } from '@typecad/hal';`);
867
+ }
868
+ ts.push('');
869
+ if (pins.length > 0) {
870
+ ts.push(`// Datasheet-named pins (derived from the board's devicetree controllers)`);
871
+ for (const p of pins) {
872
+ ts.push(`export const ${p.ident} = Pin.fromPort('${p.name}');`);
873
+ }
874
+ ts.push('');
875
+ }
876
+ for (const [label, pin] of connectorLabelOwner) {
877
+ ts.push(`/** Connector pin ${label} → ${pin.name} */`);
878
+ ts.push(`export const ${label} = ${pin.ident};`);
879
+ }
880
+ if (connectorLabelOwner.size > 0)
881
+ ts.push('');
882
+ // LED/BUTTON aliases reference pins the sweep may not carry (a pad
883
+ // outside the derived controller table's family layout). Emit them
884
+ // explicitly alongside the alias.
885
+ const extraPinDecls = [];
886
+ if (ledPinFinal && !pins.some((p) => p.ident === ledPinFinal.ident))
887
+ extraPinDecls.push(ledPinFinal);
888
+ if (buttonPin && !pins.some((p) => p.ident === buttonPin.ident) && buttonPin.ident !== ledPinFinal?.ident)
889
+ extraPinDecls.push(buttonPin);
890
+ for (const p of [...extraLedPins, ...extraButtonPins]) {
891
+ if (!pins.some((x) => x.ident === p.ident) && !extraPinDecls.some((x) => x.ident === p.ident))
892
+ extraPinDecls.push(p);
893
+ }
894
+ for (const p of extraPinDecls) {
895
+ ts.push(`/** ${p.name} (derived from the devicetree controller label). */`);
896
+ ts.push(`export const ${p.ident} = Pin.fromPort('${p.name}');`);
897
+ }
898
+ if (ledPinFinal) {
899
+ ts.push(`/** On-board LED (${entry.led ? `devicetree ${entry.led.dtSpec}` : 'addressable strip — no gpio-leds node'}). */`);
900
+ ts.push(`export const LED = ${ledPinFinal.ident};`);
901
+ }
902
+ if (buttonPin) {
903
+ ts.push(`/** User button (devicetree ${entry.button?.dtSpec}). */`);
904
+ ts.push(`export const BUTTON = ${buttonPin.ident};`);
905
+ }
906
+ if (ledPinFinal || buttonPin || extraPinDecls.length > 0)
907
+ ts.push('');
908
+ // Bus instance exports from the board's own DTS-wired controllers.
909
+ const busLines = [];
910
+ buses.i2c.forEach((_, i) => busLines.push(`export const I2C${i} = new I2CBus('I2C${i}');`));
911
+ buses.spi.forEach((_, i) => busLines.push(`export const SPI${i} = new SPIBus('SPI${i}');`));
912
+ buses.uart.forEach((_, i) => busLines.push(`export const UART${i} = new UART('UART${i}');`));
913
+ // USB CDC instance (the thin HAL's USBConsole): the board's DTS turns the
914
+ // device controller on (zephyr_udc0 status okay).
915
+ if (hasUsb)
916
+ busLines.push(`export const USB0 = new USBConsole('USB0');`);
917
+ // PWM-driven LEDs (pwm-leds): addressed by the board's own devicetree
918
+ // alias (DT_ALIAS(pwm_led0) in the lowering) — no overlay needed. The
919
+ // construction period comes from the DTS cell when it is a plain number
920
+ // (macro periods like PWM_MSEC(20) are opaque to the reader — 1 ms, the
921
+ // LED-dimming default, stands in).
922
+ pwmLedSpecs.forEach((l, i) => {
923
+ const period = l.periodNs ?? 1_000_000;
924
+ const name = i === 0 ? 'PWMLED' : `PWMLED${i}`;
925
+ busLines.push(`/** Board PWM LED (devicetree ${l.alias}${l.flags?.length ? ', ' + l.flags.join(' ') : ''}). */`);
926
+ busLines.push(`export const ${name} = new PWM(${8192 + i}, { periodNs: ${period} });`);
927
+ });
928
+ if (busLines.length > 0) {
929
+ ts.push('// Bus instance selectors (one per board-wired controller)');
930
+ ts.push(...busLines);
931
+ ts.push('');
932
+ }
933
+ // ── board.json (BoardConstants flat map + manifest) ─────────────────────
934
+ const constants = {};
935
+ constants['id'] = entry.identifier.split('/')[0];
936
+ constants['name'] = entry.name;
937
+ constants['architecture'] = soc;
938
+ constants['build.frameworks.zephyr'] = entry.identifier;
939
+ constants['zephyr.soc'] = soc;
940
+ pins.forEach((p, i) => {
941
+ constants[`pins.all.${i}.number`] = p.halPin;
942
+ constants[`pins.all.${i}.gpio`] = p.halPin;
943
+ constants[`pins.all.${i}.name`] = p.name;
944
+ // digitalIn/out, interrupt, pull-up/down, open-drain are the Zephyr base
945
+ // GPIO driver API (`gpio_pin_configure` flags + `gpio_pin_interrupt_configure`):
946
+ // every GPIO controller implements them, so they are universally true —
947
+ // a platform fact, not a per-pad one. Analog/PWM ride the harvested
948
+ // silicon routes (honest per-pad facts).
949
+ constants[`pins.all.${i}.capabilities.digitalInput`] = true;
950
+ constants[`pins.all.${i}.capabilities.digitalOutput`] = true;
951
+ constants[`pins.all.${i}.capabilities.analogInput`] = siliconAdcPins.has(p.halPin);
952
+ constants[`pins.all.${i}.capabilities.analogOutput`] = siliconDacPins.has(p.halPin);
953
+ constants[`pins.all.${i}.capabilities.pwm`] = siliconPwmPins.has(p.halPin);
954
+ constants[`pins.all.${i}.capabilities.interrupt`] = true;
955
+ constants[`pins.all.${i}.capabilities.pullUp`] = true;
956
+ constants[`pins.all.${i}.capabilities.pullDown`] = true;
957
+ // Capacitive touch has no harvested source yet (ESP32 touch pads live in
958
+ // the pinctrl header matrix) — honestly false until a source lands.
959
+ constants[`pins.all.${i}.capabilities.touch`] = false;
960
+ constants[`pins.all.${i}.capabilities.openDrain`] = true;
961
+ });
962
+ constants['peripherals.i2c.count'] = buses.i2c.length;
963
+ constants['peripherals.spi.count'] = buses.spi.length;
964
+ constants['peripherals.uart.count'] = buses.uart.length;
965
+ buses.i2c.forEach((_, i) => constants[`peripherals.i2c.${i}.instance`] = i);
966
+ buses.spi.forEach((_, i) => constants[`peripherals.spi.${i}.instance`] = i);
967
+ buses.uart.forEach((_, i) => constants[`peripherals.uart.${i}.instance`] = i);
968
+ // Board pwm-led specs (virtual pins 8192+, dtSpec = the board's alias).
969
+ // resolveChipFromBoard merges these into the active chip's pwm specs.
970
+ pwmLedSpecs.forEach((l, i) => {
971
+ constants[`zephyr.pwm.specs.${i}.pin`] = 8192 + i;
972
+ constants[`zephyr.pwm.specs.${i}.dtSpec`] = l.alias;
973
+ });
974
+ // Silicon PWM specs (real pads, pinctrl-routed) continue the index space
975
+ // after the board-alias virtual-pin specs so the two never collide. The
976
+ // overlay enables each referenced controller and derives the STM32 16-bit
977
+ // prescaler from zephyr.pwm.clockHz.
978
+ siliconPwm.forEach((s, i) => {
979
+ const idx = pwmLedSpecs.length + i;
980
+ constants[`zephyr.pwm.specs.${idx}.pin`] = s.pin;
981
+ constants[`zephyr.pwm.specs.${idx}.controller`] = s.controller;
982
+ constants[`zephyr.pwm.specs.${idx}.channel`] = s.channel;
983
+ if (s.pinctrl)
984
+ constants[`zephyr.pwm.specs.${idx}.pinctrl`] = s.pinctrl;
985
+ });
986
+ // Timer input clocks under the family's default clock tree (PWM_CLOCK_HZ
987
+ // at module scope). Omitted when unknown — the overlay then leaves the
988
+ // SoC-default prescaler in place.
989
+ if (siliconPwm.length > 0) {
990
+ const clock = Object.entries(PWM_CLOCK_HZ).find(([k]) => soc.startsWith(k))?.[1];
991
+ if (clock)
992
+ constants['zephyr.pwm.clockHz'] = clock;
993
+ }
994
+ // ADC facts: the first harvested controller's channels with the family
995
+ // resolution/vref (STM32: 12-bit nominal VREF; ESP32 SARADC: 12-bit
996
+ // against the ~1.1 V internal reference). The overlay enables the
997
+ // controller and wires the used channels' pinctrl groups (STM32 only —
998
+ // ESP32 pads are analog by silicon, no groups).
999
+ if (siliconAdc.length > 0) {
1000
+ const isEsp = !!(entry.espAdc ?? []).length;
1001
+ constants['zephyr.adc.nodeLabel'] = isEsp ? (espAdcUnit ?? 'adc0') : adcSources[0];
1002
+ constants['zephyr.adc.resolution'] = 12;
1003
+ // vref: STM32 = VDDA nominal (3300); ESP32 SARADC = the ~1.1 V internal
1004
+ // reference; nRF SAADC = 3000 (the scheme the lowering's gain-1/4 +
1005
+ // internal-reference default assumes — matches the descriptor the XIAO's
1006
+ // hardware suite validated).
1007
+ constants['zephyr.adc.vrefMv'] = isEsp ? 1100 : nrfSaadcSynth ? 3000 : 3300;
1008
+ // The channel-setup gain/reference pair (ADC_CHANNEL_CFG at module
1009
+ // scope). Absent = the lowering's default (the nRF SAADC scheme:
1010
+ // ADC_GAIN_1_4 against ADC_REF_INTERNAL).
1011
+ const adcCfg = !isEsp
1012
+ ? Object.entries(ADC_CHANNEL_CFG).find(([k]) => soc.startsWith(k))?.[1]
1013
+ : undefined;
1014
+ if (adcCfg) {
1015
+ constants['zephyr.adc.gain'] = adcCfg.gain;
1016
+ constants['zephyr.adc.reference'] = adcCfg.reference;
1017
+ }
1018
+ siliconAdc.forEach((a, i) => {
1019
+ constants[`zephyr.adc.channels.${i}.pin`] = a.pin;
1020
+ constants[`zephyr.adc.channels.${i}.channel`] = a.channel;
1021
+ if (a.pinctrl)
1022
+ constants[`zephyr.adc.channels.${i}.pinctrl`] = a.pinctrl;
1023
+ if (a.controller)
1024
+ constants[`zephyr.adc.channels.${i}.controller`] = a.controller;
1025
+ });
1026
+ }
1027
+ // ESP32 LEDC matrix: any listed pad can carry any of channelCount
1028
+ // channels; the overlay assigns channels to the driven pads at build
1029
+ // time. Pins are global pad numbers (gpio0 base 0).
1030
+ if (pwmMatrix) {
1031
+ const ctrl = controllers.find((c) => c.nodelabel === 'gpio0');
1032
+ const base = ctrl ? ctrl.minPin : 0;
1033
+ constants['zephyr.pwm.matrix.controller'] = pwmMatrix.controller;
1034
+ constants['zephyr.pwm.matrix.channelCount'] = pwmMatrix.channelCount;
1035
+ pwmMatrix.pads.forEach((pad, i) => {
1036
+ constants[`zephyr.pwm.matrix.pins.${i}`] = base + pad;
1037
+ });
1038
+ }
1039
+ // DAC facts: the first harvested controller's output channels (STM32
1040
+ // 12-bit). The pinctrl source IS the DT nodelabel on STM32 (dac1), so it
1041
+ // maps straight to the device spec; the overlay enables the node and
1042
+ // wires the used channels' pinctrl groups.
1043
+ if (siliconDac.length > 0) {
1044
+ constants['zephyr.dac.device'] = dacSources[0];
1045
+ // ESP32's DAC is 8-bit; the STM32 DAC is 12-bit (the default).
1046
+ const dacResolution = esp32Dac ? 8 : 12;
1047
+ siliconDac.forEach((d, i) => {
1048
+ constants[`zephyr.dac.channels.${i}.pin`] = d.pin;
1049
+ constants[`zephyr.dac.channels.${i}.channel`] = d.channel;
1050
+ constants[`zephyr.dac.channels.${i}.resolution`] = dacResolution;
1051
+ constants[`zephyr.dac.channels.${i}.pinctrl`] = d.pinctrl;
1052
+ });
1053
+ }
1054
+ if (ledPinFinal)
1055
+ constants['pins.aliases.LED'] = ledPinFinal.name;
1056
+ if (buttonPin)
1057
+ constants['pins.aliases.BUTTON'] = buttonPin.name;
1058
+ // Extra LEDs / buttons beyond the canonical first — indexed aliases
1059
+ // INCLUDE the canonical first: LED0 = the first (same as LED), LED1 =
1060
+ // the second, etc. (the dtSpec numbering led0, led1, led2 lines up).
1061
+ if (ledPinFinal)
1062
+ constants['pins.aliases.LED0'] = ledPinFinal.name;
1063
+ if (buttonPin)
1064
+ constants['pins.aliases.BUTTON0'] = buttonPin.name;
1065
+ extraLedPins.forEach((p, i) => {
1066
+ constants[`pins.aliases.LED${i + 1}`] = p.name;
1067
+ });
1068
+ extraButtonPins.forEach((p, i) => {
1069
+ constants[`pins.aliases.BUTTON${i + 1}`] = p.name;
1070
+ });
1071
+ // Connector labels (D0–D20, A0–A10, SDA/SCL/MOSI/MISO/SCK/RX/TX/CS,
1072
+ // GP0–GP29, …) — every uncollided one becomes a pins.aliases.<label>
1073
+ // entry so `resolveHALReceiver` treats them as Pin constants (the same
1074
+ // path LED/BUTTON ride). The board.ts export already exists
1075
+ // (connectorLabelOwner); this makes the manifest agree.
1076
+ for (const [label, pin] of connectorLabelOwner) {
1077
+ constants[`pins.aliases.${label}`] = pin.name;
1078
+ }
1079
+ // Analog silkscreen offset: the lowest-numbered A-label's underlying HAL
1080
+ // pin. The hal-parser's A<N> fallback (no-manifest path) uses this to
1081
+ // map A0 → the board's actual analog base instead of assuming 14.
1082
+ const aLabels = connectorExports
1083
+ .filter(({ label }) => /^A\d+$/.test(label))
1084
+ .sort((a, b) => parseInt(a.label.slice(1), 10) - parseInt(b.label.slice(1), 10));
1085
+ if (aLabels.length > 0) {
1086
+ const a0 = aLabels[0];
1087
+ const idx = pins.indexOf(a0.pin);
1088
+ if (idx >= 0) {
1089
+ constants['pins.analogOffset'] = a0.pin.halPin;
1090
+ }
1091
+ }
1092
+ // ── GPIO controller routing ────────────────────────────────────────────
1093
+ // The strategy reconstructs its controller table from these constants via
1094
+ // resolveChipFromBoard — without them the GPIO lowering would default to
1095
+ // a `gpio0` nodelabel that doesn't exist on most vendors (undefined
1096
+ // `__device_dts_ord_…` at C++ compile time).
1097
+ controllers.forEach((c, i) => {
1098
+ constants[`zephyr.gpioControllers.${i}.nodelabel`] = c.nodelabel;
1099
+ constants[`zephyr.gpioControllers.${i}.minPin`] = c.minPin;
1100
+ constants[`zephyr.gpioControllers.${i}.maxPin`] = c.maxPin;
1101
+ });
1102
+ // Devicetree alias specs for the board's own LEDs/buttons: the lowering
1103
+ // prefers the dtSpec path (gpio_pin_set_dt), which honors the node's
1104
+ // polarity flags from the board DTS. They double as the canonical
1105
+ // interrupt sources (the board's buttons).
1106
+ const dtSpecEntries = [];
1107
+ if (entry.led && ledPin)
1108
+ dtSpecEntries.push({ pin: ledPin.halPin, dtSpec: entry.led.dtSpec });
1109
+ if (entry.button && buttonPin)
1110
+ dtSpecEntries.push({ pin: buttonPin.halPin, dtSpec: entry.button.dtSpec });
1111
+ (entry.extraLeds ?? []).forEach((l, i) => {
1112
+ const p = extraLedPins[i];
1113
+ if (p)
1114
+ dtSpecEntries.push({ pin: p.halPin, dtSpec: l.dtSpec });
1115
+ });
1116
+ (entry.extraButtons ?? []).forEach((b, i) => {
1117
+ const p = extraButtonPins[i];
1118
+ if (p)
1119
+ dtSpecEntries.push({ pin: p.halPin, dtSpec: b.dtSpec });
1120
+ });
1121
+ dtSpecEntries.forEach((s, i) => {
1122
+ constants[`zephyr.gpio.dtSpecs.${i}.pin`] = s.pin;
1123
+ constants[`zephyr.gpio.dtSpecs.${i}.dtSpec`] = s.dtSpec;
1124
+ constants[`zephyr.gpio.interruptPins.${i}.pin`] = s.pin;
1125
+ constants[`zephyr.gpio.interruptPins.${i}.dtSpec`] = s.dtSpec;
1126
+ });
1127
+ // Bus controllers the board's own DTS wires up — resolveChipFromBoard
1128
+ // reconstructs the bus lists from these, and the overlays enable the
1129
+ // nodes the program uses.
1130
+ buses.i2c.forEach((nodelabel, i) => {
1131
+ constants[`zephyr.i2c.controllers.${i}.nodeLabel`] = nodelabel;
1132
+ });
1133
+ buses.spi.forEach((nodelabel, i) => {
1134
+ constants[`zephyr.spi.controllers.${i}.nodeLabel`] = nodelabel;
1135
+ });
1136
+ buses.uart.forEach((nodelabel, i) => {
1137
+ constants[`zephyr.uart.controllers.${i}.nodeLabel`] = nodelabel;
1138
+ });
1139
+ // USB device (CDC-ACM): the board's DTS turned the controller on.
1140
+ if (hasUsb) {
1141
+ constants['zephyr.usb.controller'] = entry.usbController ?? 'zephyr_udc0';
1142
+ constants['zephyr.usb.cdcInstances'] = 1;
1143
+ }
1144
+ // Watchdog: the board's devicetree watchdog0 alias.
1145
+ if (entry.wdtNodeLabel) {
1146
+ constants['zephyr.wdt.nodeLabel'] = entry.wdtNodeLabel;
1147
+ }
1148
+ // Storage partition facts (Store/FS). Boards whose DTS ships a
1149
+ // storage_partition (ESP32's AMP layout) carry its REAL reg — the overlay
1150
+ // then writes only the /chosen pointer, never a redeclaration. Boards
1151
+ // without one get a region synthesized near the TOP of flash (the app
1152
+ // image grows from the bottom, so the end is the conventional safe
1153
+ // region). Region = 1/8 of flash, clamped to [16KB, 128KB], 4KB-aligned.
1154
+ if (entry.storageReg) {
1155
+ constants['zephyr.storage.offset'] = entry.storageReg.offsetBytes;
1156
+ constants['zephyr.storage.size'] = entry.storageReg.sizeBytes;
1157
+ constants['zephyr.storage.preexisting'] = true;
1158
+ }
1159
+ else if (entry.flashKb && !entry.hasStoragePartition) {
1160
+ const regionKb = Math.min(128, Math.max(16, Math.floor(entry.flashKb / 8 / 4) * 4));
1161
+ constants['zephyr.storage.offset'] = (entry.flashKb - regionKb) * 1024;
1162
+ constants['zephyr.storage.size'] = regionKb * 1024;
1163
+ }
1164
+ // Hardware counters: the free-counter list was computed above (with the
1165
+ // kernel-claim exclusions); emit the controller facts. The child form
1166
+ // (ESP32 timers) carries the parent the overlay attaches the label to.
1167
+ hwtimerControllers.forEach((c, i) => {
1168
+ constants[`zephyr.hwtimer.controllers.${i}.nodeLabel`] = c.nodeLabel ?? `tc_counter${i}`;
1169
+ if (c.parentLabel)
1170
+ constants[`zephyr.hwtimer.controllers.${i}.counterParent`] = c.parentLabel;
1171
+ });
1172
+ // Probe methods — the debug-config writer and `west flash` runner choice
1173
+ // read them from the board constants as zephyr.probeMethods.N.*. The
1174
+ // table is the board's own board.cmake runners, so `zephyr.probe`
1175
+ // resolves on every board that declares runners.
1176
+ (entry.probeMethods ?? []).forEach((pm, i) => {
1177
+ constants[`zephyr.probeMethods.${i}.id`] = pm.id;
1178
+ constants[`zephyr.probeMethods.${i}.runner`] = pm.runner;
1179
+ if (pm.args)
1180
+ constants[`zephyr.probeMethods.${i}.args`] = pm.args.join(',');
1181
+ if (pm.description)
1182
+ constants[`zephyr.probeMethods.${i}.description`] = pm.description;
1183
+ if (pm.debug !== undefined)
1184
+ constants[`zephyr.probeMethods.${i}.debug`] = pm.debug;
1185
+ if (pm.debugInterface)
1186
+ constants[`zephyr.probeMethods.${i}.debugInterface`] = pm.debugInterface;
1187
+ if (pm.debugDevice)
1188
+ constants[`zephyr.probeMethods.${i}.debugDevice`] = pm.debugDevice;
1189
+ // cfg lines join on newlines — they legally contain commas (tcl event
1190
+ // blocks), which the comma convention would corrupt.
1191
+ if (pm.debugCfg)
1192
+ constants[`zephyr.probeMethods.${i}.debugCfg`] = pm.debugCfg.join('\n');
1193
+ const src = pm.debugCfgSource;
1194
+ if (src)
1195
+ constants[`zephyr.probeMethods.${i}.debugCfgSource`] = src.join('\n');
1196
+ });
1197
+ const manifest = {
1198
+ version: 1,
1199
+ identifier: entry.identifier,
1200
+ soc,
1201
+ pinNames: pins.map((p) => p.name),
1202
+ constants,
1203
+ // Source fingerprint: covers the record content, the extraction
1204
+ // revision, and the tree provenance (when the record came from a
1205
+ // catalog). `cuttlefish build` recomputes it cheaply and regenerates
1206
+ // this module when it moves — a board change in the config, the catalog
1207
+ // overlay, or the Zephyr tree itself recreates the module.
1208
+ source: {
1209
+ generatorRev: GENERATOR_REV,
1210
+ fingerprint: boardRecordFingerprint(entry, loadBoardCatalogOverlay()) + factsSuffix,
1211
+ },
1212
+ };
1213
+ return {
1214
+ boardTs: ts.join('\n'),
1215
+ boardJson: JSON.stringify(manifest, null, 1),
1216
+ board: entry,
1217
+ ...(userWarnings.length > 0 ? { warnings: userWarnings } : {}),
1218
+ };
1219
+ }
1220
+ /**
1221
+ * Generate the board module contents for a qualified Zephyr board target
1222
+ * ('esp32s3_devkitc/esp32s3/procpu'). Bare SoC names are not targets —
1223
+ * every board resolves through the catalog like any other.
1224
+ */
1225
+ export function generateBoard(target, opts) {
1226
+ const found = findBoardData(target);
1227
+ if (!found) {
1228
+ const hint = loadBoardCatalogOverlay()
1229
+ ? `'${target}' is not a board target in the current catalog. ` +
1230
+ `It may be new in your Zephyr tree — run 'cuttlefish board sync' and retry.`
1231
+ : `No board catalog on this machine. Run 'cuttlefish board sync' first.`;
1232
+ throw new Error(hint);
1233
+ }
1234
+ // The as-built snapshot (this project's last successful build's resolved
1235
+ // zephyr.dts): the stable name-grammar routes shadow the walker's harvest
1236
+ // per pin — a broken catalog regex cannot reach the user's board module.
1237
+ // Ignored when absent, unparseable, or written for a different board.
1238
+ let entry = found;
1239
+ const asBuiltWarnings = [];
1240
+ let asBuiltApplied = false;
1241
+ if (opts?.asBuiltJson !== undefined) {
1242
+ try {
1243
+ const asBuilt = parseAsBuiltJson(opts.asBuiltJson);
1244
+ if (asBuilt.board.toLowerCase() === found.identifier.toLowerCase()) {
1245
+ entry = mergeAsBuilt(found, asBuilt, asBuiltWarnings);
1246
+ asBuiltApplied = true;
1247
+ }
1248
+ }
1249
+ catch (err) {
1250
+ asBuiltWarnings.push(`ignoring as-built snapshot: ${err.message}`);
1251
+ }
1252
+ }
1253
+ // The project's cuttlefish.facts.json, when present: the section for THIS
1254
+ // board merges into the manifest (user routes win per pin), and the raw
1255
+ // text hashes into the module's source fingerprint so edits regenerate.
1256
+ let facts;
1257
+ let suffix = '';
1258
+ if (opts?.factsJson !== undefined) {
1259
+ facts = userFactsForTarget(parseUserFactsJson(opts.factsJson), entry.identifier) ?? undefined;
1260
+ suffix = factsFingerprint(opts.factsJson);
1261
+ }
1262
+ // The hash only joins the fingerprint when the snapshot actually applied
1263
+ // (a foreign-board snapshot is ignored and must not force regeneration).
1264
+ if (asBuiltApplied && opts?.asBuiltJson !== undefined)
1265
+ suffix += factsFingerprint(opts.asBuiltJson);
1266
+ return buildModule(entry, facts, suffix, asBuiltWarnings);
1267
+ }
1268
+ /**
1269
+ * Merge as-built routes over the walker's harvested routes per PAD
1270
+ * (port+bit). Identical routes pass silently; a disagreement warns and the
1271
+ * as-built route wins — the resolved devicetree is ground truth for
1272
+ * DT-expressible facts. Analog-device cross-checks are not re-applied: the
1273
+ * build already proved the device exists.
1274
+ */
1275
+ function mergeAsBuilt(entry, asBuilt, warnings) {
1276
+ const merge = (harvested, asBuiltRoutes, kind) => {
1277
+ const out = [];
1278
+ const asByPad = new Map(asBuiltRoutes.map((r) => [`${r.port}${r.bit}`, r]));
1279
+ for (const h of harvested ?? []) {
1280
+ const a = asByPad.get(`${h.port}${h.bit}`);
1281
+ if (!a) {
1282
+ out.push(h);
1283
+ continue;
1284
+ }
1285
+ asByPad.delete(`${h.port}${h.bit}`);
1286
+ if (a.source !== h.source || a.channel !== h.channel) {
1287
+ warnings.push(`as-built ${kind} on ${h.port}${h.bit}: build says ${a.source} ch ${a.channel}, ` +
1288
+ `harvest said ${h.source} ch ${h.channel} — the build wins.`);
1289
+ }
1290
+ out.push({ ...h, source: a.source, channel: a.channel, pinctrl: a.pinctrl });
1291
+ }
1292
+ // As-built pads the harvest never found (a broken regex's blind spot).
1293
+ for (const a of asByPad.values()) {
1294
+ out.push({ source: a.source, channel: a.channel, port: a.port, bit: a.bit, pinctrl: a.pinctrl });
1295
+ }
1296
+ return out;
1297
+ };
1298
+ return {
1299
+ ...entry,
1300
+ adcPins: merge(entry.adcPins, asBuilt.routes.adcPins, 'adc route'),
1301
+ pwmPins: merge(entry.pwmPins, asBuilt.routes.pwmPins, 'pwm route'),
1302
+ dacPins: merge(entry.dacPins, asBuilt.routes.dacPins, 'dac route'),
1303
+ };
1304
+ }
1305
+ /**
1306
+ * Generate the board module for a CONTRACT board — a custom PCB with no
1307
+ * Zephyr board record. The contract (exported from a TypeCAD project)
1308
+ * supplies the wired pads by datasheet name and which bus families the PCB
1309
+ * routes; the SoC's bus controller nodelabels come from the INSTALLED
1310
+ * Zephyr tree's soc dtsi (the SDK is the source of truth — a contract build
1311
+ * compiles against that tree).
1312
+ *
1313
+ * Returns a full board module (the transpiler's pin map + chip resolution
1314
+ * read board.json). The caller layers the narrowed board.ts on top, so the
1315
+ * firmware can only touch pads the PCB actually wired.
1316
+ */
1317
+ export function generateBoardModuleFromContract(opts) {
1318
+ const pads = [];
1319
+ for (const name of opts.pinNames) {
1320
+ const parsed = parsePinName(opts.soc, name);
1321
+ if (!parsed) {
1322
+ console.error(`contract: pad '${name}' does not match ${opts.soc} naming conventions — skipped`);
1323
+ continue;
1324
+ }
1325
+ pads.push({ name, controller: parsed.controller, pin: parsed.pin });
1326
+ }
1327
+ if (pads.length === 0) {
1328
+ throw new Error(`None of the contract's pads (${opts.pinNames.join(', ')}) match ${opts.soc} naming ` +
1329
+ `conventions — name them by their datasheet form (PA5, P0.28, GP25, GPIO9).`);
1330
+ }
1331
+ // SoC bus labels from the installed tree, gated to the wired families.
1332
+ const treeBuses = socBusLabelsFromTree(opts.zephyrBase, opts.soc);
1333
+ const buses = {
1334
+ i2c: opts.peripherals.i2c ? treeBuses.i2c : [],
1335
+ spi: opts.peripherals.spi ? treeBuses.spi : [],
1336
+ uart: opts.peripherals.uart ? treeBuses.uart : [],
1337
+ };
1338
+ // Synthetic catalog record shaped like a walker record, routed through the
1339
+ // SAME module builder as every board: the wired pads ride in as a
1340
+ // 'contract' connector (labels = datasheet names), and deriveControllers
1341
+ // builds the controller table from them like it does for any board.
1342
+ // Functional aliases (TX = PA9) ride the contract connector under a
1343
+ // second label — buildModule's connectorLabelOwner exports them as
1344
+ // TX = PA9 and stamps pins.aliases.TX.
1345
+ const padEntries = pads.map((p) => [p.name, { controller: p.controller, pin: p.pin, flags: [] }]);
1346
+ for (const a of opts.padAliases ?? []) {
1347
+ const pad = pads.find((p) => p.name === a.padName);
1348
+ if (pad)
1349
+ padEntries.push([a.exportName, { controller: pad.controller, pin: pad.pin, flags: [] }]);
1350
+ }
1351
+ const entry = {
1352
+ identifier: `contract/${opts.soc}`,
1353
+ name: `${opts.soc} custom board (contract)`,
1354
+ vendor: 'typecad',
1355
+ dts: '',
1356
+ buses: Object.values(buses).some((b) => b.length > 0) ? buses : undefined,
1357
+ connectors: padEntries.length > 0
1358
+ ? [{
1359
+ nodelabel: 'contract',
1360
+ compatible: 'typecad,contract',
1361
+ pins: Object.fromEntries(padEntries),
1362
+ }]
1363
+ : undefined,
1364
+ };
1365
+ return buildModule(entry);
1366
+ }