spice: unify silkscreen->GPIO mapping on boardPinToNumber (fixes STM32 outputs, nano-esp32 buttons, esp32-family SPICE analog)

Three parallel half-implementations of the pin-name mapping each broke a
different board family (2026-07 emulation-gaps audit):

- collectPinStates.pinNameToArduinoPin returned -1 for STM32 'PC13' (no
  V source stamped: LED dark with correct firmware) and for nano-esp32
  'D2'/'A0' (no pull resistor stamped for INPUT_PULLUP buttons).
- connectDigitalInputsToMcu.gpioFromPinName only knew digits/GPIO/GP, so
  labeled pins were never driven from the solve (buttons stuck LOW).
- connectAnalogInputsToMcu's ADC_PIN_MAP used 'GPIO32'-style lookup keys
  that never matched the real bare-number wire pin names, so the
  SPICE->ADC path skipped every esp32-family pin; its per-kind GPIO
  converters also mapped nano-esp32 A-pins with the AVR convention.

All three now delegate to utils/boardPinMapping.boardPinToNumber (with
the previous behavior as fallback for unknown names). Also adds the
ESP32-C3 branch to the store's adcChannelForPin (GPIO0-5 -> CH0-5,
verified against the qemu SARADC channel layout).
This commit is contained in:
David Montero 2026-07-31 19:20:59 +02:00
parent cd5cbb14f3
commit a18964b401
4 changed files with 66 additions and 24 deletions

View File

@ -13,6 +13,7 @@ import type { BoardKind } from '../../types/board';
import { isStm32BoardKind } from '../../types/board';
import { stm32PinNameToLinear } from '../Stm32Bridge';
import { BOARD_PIN_GROUPS } from './boardPinGroups';
import { boardPinToNumber } from '../../utils/boardPinMapping';
/**
* Convert a board pin name (e.g. "9", "A0", "GP26", "GPIO32") to the
@ -27,21 +28,29 @@ import { BOARD_PIN_GROUPS } from './boardPinGroups';
export function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number {
const group = BOARD_PIN_GROUPS[boardKind] ?? BOARD_PIN_GROUPS.default;
if (group.gnd.includes(pinName) || group.vcc_pins.includes(pinName)) return -1;
// 'GPIO' must be tested BEFORE 'GP': the GP branch used to shadow it,
// turning 'GPIO32' into parseInt('IO32') = NaN → -1 (dead branch).
// Single source of truth: utils/boardPinMapping knows every family's
// silkscreen->GPIO map (nano-esp32 D2->GPIO5, xiao D-pins, STM32
// PC13->linear 45, Pico GP*, ...). This function used to be a parallel
// half-implementation: 'PC13'/'D2' fell through to -1, so STM32 outputs
// never got a V source stamped (LED dark with correct firmware, lianqi
// 07-23) and nano-esp32 INPUT_PULLUP pins never got their pull resistor
// (dead START button, prabkagi 07-21 — 2026-07 emulation-gaps audit).
const n = boardPinToNumber(boardKind, pinName);
if (n != null) return n;
// Fallbacks for names boardPinToNumber doesn't know (older saved
// projects / generic boards): GPIO before GP so 'GPIO32' never parses
// as 'IO32'; A<n> uses the AVR convention; ATtiny85 PB<n> is identity.
if (pinName.startsWith('GPIO')) {
const n = parseInt(pinName.slice(4), 10);
return Number.isFinite(n) ? n : -1;
const g = parseInt(pinName.slice(4), 10);
return Number.isFinite(g) ? g : -1;
}
if (pinName.startsWith('GP')) {
const n = parseInt(pinName.slice(2), 10);
return Number.isFinite(n) ? n : -1;
const g = parseInt(pinName.slice(2), 10);
return Number.isFinite(g) ? g : -1;
}
if (/^A\d+$/.test(pinName)) {
return 14 + parseInt(pinName.slice(1), 10);
}
// ATtiny85 port-style names (PB0..PB5) map 1:1 to Arduino pin numbers
// because ATTinyCore's pinMap is identity for the tiny85 variant.
if (/^PB\d+$/.test(pinName)) {
return parseInt(pinName.slice(2), 10);
}

View File

@ -30,6 +30,7 @@ import { useElectricalStore } from '../../store/useElectricalStore';
import { setAdcVoltage } from '../parts/partUtils';
import type { BoardKind } from '../../types/board';
import { interpolateAt } from './waveformStats';
import { boardPinToNumber } from '../../utils/boardPinMapping';
// Which Arduino-style pin name maps to which ADC channel, per board.
function adcRange(prefix: string, start: number, count: number) {
@ -72,17 +73,21 @@ const ADC_PIN_MAP: Partial<Record<BoardKind, Array<{ pinName: string; channel: n
{ pinName: 'GP29', channel: 3 },
],
// ESP32
esp32: adcRange('GPIO', 32, 8),
'esp32-devkit-c-v4': adcRange('GPIO', 32, 8),
'esp32-cam': adcRange('GPIO', 32, 8),
// ESP32 — pinNames here are lookup keys into pinNetMap, so they MUST be
// the boards' REAL silkscreen names. The old 'GPIO32'-style keys never
// matched the bare-number pin names actual wires carry ('34', '4', 'A0'),
// so the SPICE->ADC path silently skipped every esp32-family pin
// (2026-07 emulation-gaps audit; divider circuits read 0 forever).
esp32: adcRange('', 32, 8),
'esp32-devkit-c-v4': adcRange('', 32, 8),
'esp32-cam': adcRange('', 32, 8),
'wemos-lolin32-lite': adcRange('GPIO', 32, 8),
'esp32-s3': adcRange('GPIO', 1, 10),
'xiao-esp32-s3': adcRange('GPIO', 1, 10),
'esp32-s3': adcRange('', 1, 10),
'xiao-esp32-s3': adcRange('D', 0, 9),
'arduino-nano-esp32': adcRange('A', 0, 8),
'esp32-c3': adcRange('GPIO', 0, 6),
'xiao-esp32-c3': adcRange('GPIO', 0, 6),
'aitewinrobot-esp32c3-supermini': adcRange('GPIO', 0, 6),
'esp32-c3': adcRange('', 0, 6),
'xiao-esp32-c3': adcRange('D', 0, 4),
'aitewinrobot-esp32c3-supermini': adcRange('', 0, 6),
};
function avrPinFromName(_name: string, channel: number): number {
@ -114,6 +119,22 @@ const ADC_PIN_TO_GPIO: Partial<Record<BoardKind, (pinName: string, channel: numb
'aitewinrobot-esp32c3-supermini': gpioPinFromName,
};
/** 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
* legacy per-kind converters (their sims expect Arduino pin numbers, not
* GPIOs e.g. uno 'A0' -> 14). */
function gpioForBoardPin(kind: BoardKind, pinName: string, channel: number): number {
if (kind === 'esp32' || kind.startsWith('esp32') || kind.startsWith('xiao-esp32')
|| kind === 'arduino-nano-esp32' || kind === 'wemos-lolin32-lite'
|| kind === 'aitewinrobot-esp32c3-supermini') {
const n = boardPinToNumber(kind, pinName);
if (n != null && n >= 0) return n;
}
const legacy = ADC_PIN_TO_GPIO[kind];
return legacy ? legacy(pinName, channel) : -1;
}
export function connectAnalogInputsToMcu(): () => void {
const patchedAdcs = new WeakSet<object>();
const qemuWaveformChannels = new Set<string>();
@ -138,8 +159,8 @@ export function connectAnalogInputsToMcu(): () => void {
const v = nodeVoltages[netName];
if (v == null) continue;
const clamped = Math.max(0, Math.min(vMax, v));
const gpioPin = ADC_PIN_TO_GPIO[board.boardKind]?.(pinName, channel);
if (gpioPin != null) setAdcVoltage(sim, gpioPin, clamped);
const gpioPin = gpioForBoardPin(board.boardKind, pinName, channel);
if (gpioPin >= 0) setAdcVoltage(sim, gpioPin, clamped);
}
}
}
@ -168,8 +189,7 @@ export function connectAnalogInputsToMcu(): () => void {
setAdcWaveform?: (pin: number, samples: Uint16Array, periodNs: number) => boolean;
};
if (typeof shim.setAdcWaveform !== 'function') continue;
const gpioFn = ADC_PIN_TO_GPIO[board.boardKind];
if (!gpioFn) continue;
const gpioFn = (pn: string, ch: number) => gpioForBoardPin(board.boardKind, pn, ch);
const boardId = board.id;
const seen = new Set<number>();

View File

@ -22,7 +22,9 @@
import { useSimulatorStore, getBoardSimulator, getBoardPinManager } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
import { isStm32BoardKind } from '../../types/board';
import type { BoardKind } from '../../types/board';
import { stm32PinNameToLinear } from '../Stm32Bridge';
import { boardPinToNumber } from '../../utils/boardPinMapping';
// 3.3 V LVCMOS thresholds with a hysteresis band so a node hovering near the
// midpoint doesn't chatter. A pulled-up idle input sits at ~3.3 V and a
@ -31,8 +33,13 @@ const V_HIGH = 2.0;
const V_LOW = 0.8;
/** Map a board pin name to a plain GPIO number, or -1 if it isn't one we
* drive digitally (GND/VCC/UART-named pads, etc.). */
function gpioFromPinName(name: string): number {
* drive digitally (GND/VCC/UART-named pads, etc.). Delegates to the
* canonical silkscreen->GPIO table so labeled pins resolve too the old
* digits/GPIO-only regex returned -1 for nano-esp32 'D2'/'A0', which left
* INPUT_PULLUP buttons permanently LOW on that family (2026-07 audit). */
function gpioFromPinName(name: string, boardKind: BoardKind): number {
const n = boardPinToNumber(boardKind, name);
if (n != null) return n;
if (/^\d+$/.test(name)) return parseInt(name, 10); // "4", "15"
const m = name.match(/^GPIO(\d+)$/i) || name.match(/^GP(\d+)$/i);
return m ? parseInt(m[1], 10) : -1;
@ -61,7 +68,7 @@ export function connectDigitalInputsToMcu(): () => void {
for (const [key, net] of pinNetMap) {
if (!key.startsWith(prefix)) continue;
const pinName = key.slice(prefix.length);
const gpio = isStm32 ? stm32PinNameToLinear(pinName) : gpioFromPinName(pinName);
const gpio = isStm32 ? stm32PinNameToLinear(pinName) : gpioFromPinName(pinName, board.boardKind);
if (gpio < 0) continue;
if (driven.has(gpio)) continue; // the MCU drives this pin (digitalWrite)
// Only drive pins whose net is backed by a real source/element (rail,

View File

@ -225,6 +225,12 @@ class Esp32BridgeShim {
if (pin >= 11 && pin <= 20) return 10 + (pin - 11);
return -1;
}
if (kind === 'esp32-c3' || kind === 'xiao-esp32-c3' || kind === 'aitewinrobot-esp32c3-supermini') {
// C3: ADC1 = GPIO0-4 -> CH0-4, GPIO5 (ADC2_CH0) -> index 5.
// Verified against the qemu SARADC: esp32_adc_set{channel:3} is what
// analogRead(3) returns (emulation-gaps harness, 2026-07-31).
return pin >= 0 && pin <= 5 ? pin : -1;
}
if (pin >= 36 && pin <= 39) return pin - 36; // GPIO 36→CH0 … 39→CH3
if (pin >= 32 && pin <= 35) return pin - 28; // GPIO 32→CH4 … 35→CH7
return -1;