fix(spice): overlay boards were skipped before a single volt was injected

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.
This commit is contained in:
David Montero Crespo 2026-08-22 05:12:58 +02:00
parent 07ba0f729f
commit f35805175c
3 changed files with 103 additions and 3 deletions

View File

@ -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();
});
});

View File

@ -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;

View File

@ -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<Record<BoardKind, (pinName: string, channel: numb
'aitewinrobot-esp32c3-supermini': gpioPinFromName,
};
/**
* Which pads on this board reach the ADC.
*
* ADC_PIN_MAP is keyed by the BoardKind union, so an overlay board — whose kind
* is a runtime string, not a member of that union — could never appear in it
* and was skipped outright. A board registered through proBoardRegistry
* declares its own list instead (ProBoardDef.adcPins).
*/
function adcPinsFor(kind: BoardKind): Array<{ pinName: string; channel: number }> | 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;