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