From f35805175c2f8ec86ba672d632fea30823750d17 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sat, 22 Aug 2026 05:12:58 +0200 Subject: [PATCH] fix(spice): overlay boards were skipped before a single volt was injected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADC_PIN_MAP and ADC_PIN_TO_GPIO are keyed by the BoardKind union. An overlay board's kind is a runtime string that can never join that union, so connectAnalogInputsToMcu hit `if (!adcPins) continue;` at all three sites for every pro board: a divider, an LDR, an op-amp output wired to one solved correctly in SPICE and then had nowhere to go. Parts that inject their own value (a potentiometer, through partUtils) were unaffected, which is exactly what made this easy to miss — analog looked like it worked. A pro board now declares its pads through ProBoardDef.adcPins, and pad->GPIO resolution consults its own pinToNumber first. Both fall back to the existing tables untouched, so no OSS board changes behaviour. The pad NAME matters, not the GPIO: on the Pimoroni Pico Plus 2 W the analog pads are silked 26/27/28 and tied to GP40/41/42 through 1k, so the two disagree by design. --- .../src/__tests__/pro-board-adc-pins.test.ts | 65 +++++++++++++++++++ frontend/src/lib/proBoardRegistry.ts | 15 +++++ .../spice/connectAnalogInputsToMcu.ts | 26 +++++++- 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 frontend/src/__tests__/pro-board-adc-pins.test.ts diff --git a/frontend/src/__tests__/pro-board-adc-pins.test.ts b/frontend/src/__tests__/pro-board-adc-pins.test.ts new file mode 100644 index 00000000..b5357c43 --- /dev/null +++ b/frontend/src/__tests__/pro-board-adc-pins.test.ts @@ -0,0 +1,65 @@ +/** + * The SPICE analog path and overlay boards. + * + * ADC_PIN_MAP and ADC_PIN_TO_GPIO in connectAnalogInputsToMcu are keyed by the + * BoardKind union. An overlay board's kind is a runtime string that can never + * join that union, so every pro board was skipped before a single volt was + * injected — a divider or an LDR wired to one solved correctly in SPICE and + * then had nowhere to go. Parts that inject directly (a potentiometer, through + * partUtils) were unaffected, which is what made the gap easy to miss. + * + * A pro board now declares its own pads via ProBoardDef.adcPins. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { registerProBoards, getProBoard, type ProBoardDef } from '../lib/proBoardRegistry'; + +const KIND = 'test-overlay-adc-board'; + +describe('ProBoardDef.adcPins', () => { + beforeEach(() => { + registerProBoards([ + { + kind: KIND, + label: 'Overlay ADC board', + fqbn: null, + description: 'test', + tag: 'velxio-test-adc', + size: { w: 10, h: 10 }, + // The shape the Pimoroni Pico Plus 2 W uses: pads silked with a GP + // number that are wired to a different, ADC-capable GPIO. + pinToNumber: (name: string) => (name === 'GP26' ? 26 : null), + adcPins: [{ pinName: 'GP26', channel: 0 }], + } as ProBoardDef, + ]); + }); + + it('is readable from the registry, which is what the SPICE path consults', () => { + const def = getProBoard(KIND); + expect(def?.adcPins).toEqual([{ pinName: 'GP26', channel: 0 }]); + }); + + it('carries the pad name a wire uses, not the GPIO behind it', () => { + // connectAnalogInputsToMcu looks the net up by `${boardId}:${pinName}`, so + // the name has to match pinInfo. Resolving it to a GPIO is pinToNumber's + // job, and the two can disagree — that is the whole point on a board whose + // pads are tied to a second GPIO. + const def = getProBoard(KIND)!; + const pin = def.adcPins![0]; + expect(pin.pinName).toBe('GP26'); + expect(def.pinToNumber!(pin.pinName)).toBe(26); + }); + + it('leaves a board that declares nothing without an analog path', () => { + registerProBoards([ + { + kind: 'test-overlay-no-adc', + label: 'No ADC', + fqbn: null, + description: 'test', + tag: 'velxio-test-noadc', + size: { w: 10, h: 10 }, + } as ProBoardDef, + ]); + expect(getProBoard('test-overlay-no-adc')?.adcPins).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/proBoardRegistry.ts b/frontend/src/lib/proBoardRegistry.ts index db7df46d..e76d97a7 100644 --- a/frontend/src/lib/proBoardRegistry.ts +++ b/frontend/src/lib/proBoardRegistry.ts @@ -84,6 +84,21 @@ export interface ProBoardDef { /** pinInfo name -> GPIO number (power/ground pins -> -1). Falls back to the * generic numeric parse when omitted. Return null for "not mine". */ pinToNumber?: (pinName: string) => number | null; + /** + * Which pads reach the ADC, and on which channel — the overlay's own version + * of the OSS ADC_PIN_MAP. + * + * That map is keyed by the BoardKind union, which no overlay kind can join, + * so the SPICE analog path skipped every pro board outright: a divider or an + * LDR wired to one of these boards solved correctly and then had nowhere to + * go. Parts that inject directly (a potentiometer through partUtils) were + * unaffected, which is why this looked like it worked. + * + * Names are pad names as they appear in pinInfo. A pad that is analog under a + * different silk (the Pimoroni Pico Plus 2 W ties GP26/27/28 to the ADC- + * capable GP40/41/42 through 1k) is declared under the name a wire uses. + */ + adcPins?: Array<{ pinName: string; channel: number }>; /** In-browser simulator factory (e.g. the RP2350/Hazard3 emulator). The pm * argument is the store's PinManager instance. */ createSimulator?: (pm: unknown) => ProBoardSimulator; diff --git a/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts b/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts index 79b7925a..9a487a83 100644 --- a/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts +++ b/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts @@ -31,6 +31,7 @@ import { setAdcVoltage } from '../parts/partUtils'; import type { BoardKind } from '../../types/board'; import { interpolateAt } from './waveformStats'; import { boardPinToNumber } from '../../utils/boardPinMapping'; +import { getProBoard } from '../../lib/proBoardRegistry'; // Which Arduino-style pin name maps to which ADC channel, per board. function adcRange(prefix: string, start: number, count: number) { @@ -119,6 +120,18 @@ const ADC_PIN_TO_GPIO: Partial | undefined { + return ADC_PIN_MAP[kind] ?? getProBoard(kind)?.adcPins; +} + /** Silkscreen name -> GPIO for the ADC injection. ESP32-family boards go * through the canonical boardPinToNumber table (nano-esp32 'A0' -> GPIO1, * xiao 'D1' -> GPIO2, bare '34' -> 34, ...); AVR/ATtiny/Pico keep their @@ -131,6 +144,13 @@ function gpioForBoardPin(kind: BoardKind, pinName: string, channel: number): num const n = boardPinToNumber(kind, pinName); if (n != null && n >= 0) return n; } + // An overlay board owns its pad->GPIO mapping (ProBoardDef.pinToNumber); + // ADC_PIN_TO_GPIO cannot hold it for the same union reason as above. + const proDef = getProBoard(kind); + if (proDef?.pinToNumber) { + const n = proDef.pinToNumber(pinName); + if (n != null && n >= 0) return n; + } const legacy = ADC_PIN_TO_GPIO[kind]; return legacy ? legacy(pinName, channel) : -1; } @@ -148,7 +168,7 @@ export function connectAnalogInputsToMcu(): () => void { const { nodeVoltages, pinNetMap, sourcedNets } = useElectricalStore.getState(); const { boards } = useSimulatorStore.getState(); for (const board of boards) { - const adcPins = ADC_PIN_MAP[board.boardKind]; + const adcPins = adcPinsFor(board.boardKind); if (!adcPins) continue; const sim = getBoardSimulator(board.id); if (!sim) continue; @@ -187,7 +207,7 @@ export function connectAnalogInputsToMcu(): () => void { const { boards } = useSimulatorStore.getState(); const { pinNetMap, timeWaveforms } = useElectricalStore.getState(); for (const board of boards) { - const adcPins = ADC_PIN_MAP[board.boardKind]; + const adcPins = adcPinsFor(board.boardKind); if (!adcPins) continue; const sim = getBoardSimulator(board.id); if (!sim) continue; @@ -257,7 +277,7 @@ export function connectAnalogInputsToMcu(): () => void { pushEsp32Waveforms(); const { boards } = useSimulatorStore.getState(); for (const board of boards) { - const adcPins = ADC_PIN_MAP[board.boardKind]; + const adcPins = adcPinsFor(board.boardKind); if (!adcPins) continue; const sim = getBoardSimulator(board.id); if (!sim) continue;