feat(esp32): drive digital inputs from the solved circuit (real-wiring fidelity)

ESP32 digitalRead now reflects the actual circuit instead of a part-level
seed, so a button behaves like hardware — including breaking when it's
mis-wired.

- connectDigitalInputsToMcu: after each SPICE solve, threshold every ESP32
  input pin's net voltage (3.3 V LVCMOS, hysteresis) and push the level into
  QEMU. Only pins the MCU isn't driving as outputs are injected.
- Esp32BridgeShim advertises spiceDrivenInputs; the pushbutton / 6mm-button /
  slide-switch parts skip their direct setPinState seed for such boards and
  only flip the component property (pressed/value), which re-solves the
  circuit. The connector then decides the level from the real wiring.
- makePinPullHandler no longer seeds the pin; it only records the pull
  (netlist resistor) + requests a re-solve, so the read stays circuit-driven.
- GROUND_PIN_RE now matches bare numbered grounds (GND2, GND3) — the ESP32
  DevKit element labels its second pad 'GND2', which previously floated.

Net effect: a correctly-wired INPUT_PULLUP button idles HIGH and reads LOW
pressed; a button mis-wired with GND on the wrong terminal reads stuck-LOW,
matching real silicon. AVR / RP2040 keep the legacy part-seed path.
This commit is contained in:
David Montero 2026-06-24 22:52:52 +02:00
parent d38ed04f85
commit 7fb2ee3de9
6 changed files with 134 additions and 20 deletions

View File

@ -270,7 +270,10 @@ describe('NetlistBuilder — ESP32 internal pull-up (INPUT_PULLUP)', () => {
id: 'esp32',
vcc: 3.3,
pins: { '4': { type: 'input', pull: 1 } },
groundPinNames: ['GND2'],
// NOTE: "GND2" is deliberately NOT listed in groundPinNames — it must
// canonicalize to node 0 via GROUND_PIN_RE (bare GNDn spelling). This
// is the exact pin the ESP32 DevKit element labels; before the regex
// fix it floated and the pulled-up input never read a clean level.
},
],
analysis: { kind: 'op' as const },

View File

@ -3,6 +3,19 @@ import { useElectricalStore } from '../../store/useElectricalStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { emitPropertyChange } from './partUtils';
/**
* Boards whose digital inputs are driven from the SPICE solve
* (connectDigitalInputsToMcu) advertise `spiceDrivenInputs`. For those, the
* input-control parts below (button / switch) must NOT push a pin level
* directly they only flip the component property (pressed / value), which
* re-solves the circuit, and the connector decides the logic level from the
* real wiring. Pushing a seed here would bypass the wiring and make a
* mis-wired button read "correct" instead of like hardware.
*/
function spiceDriven(sim: unknown): boolean {
return !!(sim as { spiceDrivenInputs?: boolean } | null)?.spiceDrivenInputs;
}
/**
* Basic Pushbutton implementation (full-size)
*/
@ -19,15 +32,15 @@ PartSimulationRegistry.register('pushbutton', {
// this, the firmware reads LOW from the moment loop() starts and
// believes the button is permanently pressed (the classic "LED is
// always on, pressing the button does nothing" UX bug).
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true);
if (arduinoPin !== null && !spiceDriven(avrSimulator)) avrSimulator.setPinState(arduinoPin, true);
const onButtonPress = () => {
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false); // Active LOW
if (arduinoPin !== null && !spiceDriven(avrSimulator)) avrSimulator.setPinState(arduinoPin, false); // Active LOW
(element as any).pressed = true;
emitPropertyChange(componentId, 'pressed', true);
};
const onButtonRelease = () => {
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true);
if (arduinoPin !== null && !spiceDriven(avrSimulator)) avrSimulator.setPinState(arduinoPin, true);
(element as any).pressed = false;
emitPropertyChange(componentId, 'pressed', false);
};
@ -54,15 +67,15 @@ PartSimulationRegistry.register('pushbutton-6mm', {
// Same INPUT_PULLUP seeding as the full-size pushbutton — see comment
// in `register('pushbutton', ...)` above for why this is required.
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true);
if (arduinoPin !== null && !spiceDriven(avrSimulator)) avrSimulator.setPinState(arduinoPin, true);
const onPress = () => {
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false);
if (arduinoPin !== null && !spiceDriven(avrSimulator)) avrSimulator.setPinState(arduinoPin, false);
(element as any).pressed = true;
emitPropertyChange(componentId, 'pressed', true);
};
const onRelease = () => {
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true);
if (arduinoPin !== null && !spiceDriven(avrSimulator)) avrSimulator.setPinState(arduinoPin, true);
(element as any).pressed = false;
emitPropertyChange(componentId, 'pressed', false);
};
@ -87,13 +100,13 @@ PartSimulationRegistry.register('slide-switch', {
// Read initial value from element (0 or 1)
const raw = (element as any).value;
let state = raw === 1 || raw === '1';
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, state);
if (arduinoPin !== null && !spiceDriven(avrSimulator)) avrSimulator.setPinState(arduinoPin, state);
emitPropertyChange(componentId, 'value', state ? 1 : 0);
const onChange = () => {
const v = (element as any).value;
state = v === 1 || v === '1';
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, state);
if (arduinoPin !== null && !spiceDriven(avrSimulator)) avrSimulator.setPinState(arduinoPin, state);
emitPropertyChange(componentId, 'value', state ? 1 : 0);
};

View File

@ -18,7 +18,11 @@ import { UnionFind } from './unionFind';
import { componentToSpice } from './componentToSpice';
import type { BuildNetlistInput, ComponentForSpice, BoardForSpice, WireForSpice } from './types';
const GROUND_PIN_RE = /^(gnd|vss|vee|ground|gnd\.\d+)$/i;
// Matches GND, VSS, VEE, GROUND and any numbered ground pin in the common
// spellings boards actually emit: GND.1 / GND.2 (dotted), GND2 / GND3 (bare),
// GND_2 (underscore). Several dev-kit board elements label their extra ground
// pad "GND2" (no dot), which previously fell through and floated.
const GROUND_PIN_RE = /^(gnd|vss|vee|ground)([._]?\d+)?$/i;
// Deliberately excludes "V+" / "V-" (which are probe terminals) and
// "VBB" (non-standard). VCC-like pins on boards are handled via the
// board.vccPinNames list, not this regex.

View File

@ -0,0 +1,90 @@
/**
* connectDigitalInputsToMcu drive ESP32 digital input pins from the
* solved circuit, so `digitalRead()` reflects the REAL wiring.
*
* The ESP32 runs in backend QEMU; its GPIO input register is fed only by
* whatever the host injects via `esp32_gpio_in`. Historically a button was
* faked by the part layer (BasicParts seeds the pin HIGH and toggles it on
* press) which ignores the actual circuit, so a mis-wired button still
* "worked". This connector replaces that for ESP32: after every SPICE solve
* it thresholds each input pin's net voltage and pushes the logic level into
* QEMU. Now the internal pull-up (modelled as a netlist resistor), the button
* switch, the GND connection and any short are all honoured a button wired
* to the wrong terminal reads stuck-LOW, exactly like real silicon.
*
* Mirrors `connectAnalogInputsToMcu` (ADC path) and `connectChipInputsToSolve`
* (custom-chip path): it knows ONLY the electrical store shape.
*
* Only pins the MCU is NOT actively driving as outputs are injected, so we
* never fight a `digitalWrite`. Other boards (AVR / RP2040) keep the legacy
* part-seed path; only the ESP32 QEMU bridge opts in (`spiceDrivenInputs`).
*/
import { useSimulatorStore, getBoardSimulator, getBoardPinManager } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
// 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
// pressed button pulls it to ~0 V, so the band is rarely entered.
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 {
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;
}
export function connectDigitalInputsToMcu(): () => void {
// Last logic level pushed per `${boardId}:${gpio}`, so we only emit edges
// and the hysteresis band can hold the previous level. This connector is
// the sole writer of ESP32 input pins, so the cache tracks QEMU's state.
const lastLevel = new Map<string, boolean>();
function injectDigitalInputs() {
const { nodeVoltages, pinNetMap } = useElectricalStore.getState();
const { boards } = useSimulatorStore.getState();
for (const board of boards) {
const sim = getBoardSimulator(board.id) as
| { setPinState?: (pin: number, state: boolean) => void; spiceDrivenInputs?: boolean }
| null;
if (!sim?.spiceDrivenInputs || typeof sim.setPinState !== 'function') continue;
const pm = getBoardPinManager(board.id);
const driven = pm ? pm.getOutputPins() : new Set<number>();
const prefix = `${board.id}:`;
for (const [key, net] of pinNetMap) {
if (!key.startsWith(prefix)) continue;
const gpio = gpioFromPinName(key.slice(prefix.length));
if (gpio < 0) continue;
if (driven.has(gpio)) continue; // the MCU drives this pin (digitalWrite)
const v = nodeVoltages[net];
if (v == null) continue;
const stateKey = `${board.id}:${gpio}`;
const prev = lastLevel.get(stateKey);
let next: boolean;
if (v >= V_HIGH) next = true;
else if (v <= V_LOW) next = false;
else next = prev ?? false; // inside the hysteresis band — hold
if (prev === next) continue;
lastLevel.set(stateKey, next);
sim.setPinState(gpio, next);
}
}
}
const unsubResult = useElectricalStore.subscribe((state, prev) => {
if (state.nodeVoltages !== prev.nodeVoltages) injectDigitalInputs();
});
// Reset the cache when boards change (Run / Reset spawns a fresh QEMU whose
// GPIO inputs default LOW, so we must re-emit even unchanged levels).
const unsubBoards = useSimulatorStore.subscribe((state, prev) => {
if (state.boards !== prev.boards) lastLevel.clear();
});
// Initial pass for examples that pre-populate the store before mount.
injectDigitalInputs();
return () => {
unsubResult();
unsubBoards();
};
}

View File

@ -30,6 +30,7 @@ import {
type ElectricalSnapshot,
} from './CircuitSimulationService';
import { connectAnalogInputsToMcu } from './connectAnalogInputsToMcu';
import { connectDigitalInputsToMcu } from './connectDigitalInputsToMcu';
import { connectChipInputsToSolve } from './connectChipInputsToSolve';
import { connectMcuEdgesToService } from './connectMcuEdgesToService';
import { setElectricalResolveHook } from './electricalResolveHook';
@ -82,6 +83,7 @@ export function startSimulation(): () => void {
const unsubService = service.start();
const unsubAdc = connectAnalogInputsToMcu();
const unsubDigitalIn = connectDigitalInputsToMcu();
const unsubChipIn = connectChipInputsToSolve();
const unsubEdges = connectMcuEdgesToService(service);
@ -138,6 +140,7 @@ export function startSimulation(): () => void {
setElectricalResolveHook(null);
unsubService();
unsubAdc();
unsubDigitalIn();
unsubChipIn();
unsubEdges();
};

View File

@ -121,6 +121,11 @@ export const ARDUINO_POSITION = DEFAULT_BOARD_POSITION;
// can call setPinState / pinManager just like they would on a local simulator. ──
class Esp32BridgeShim {
pinManager: PinManager;
// Digital input pins are driven from the SPICE solve
// (connectDigitalInputsToMcu), not the part-level seed — so a button reads
// the real circuit (pull-up, GND, shorts) like hardware. Parts check this
// flag and skip their direct setPinState seed for this board.
readonly spiceDrivenInputs = true;
onSerialData: ((ch: string) => void) | null = null;
onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null;
onBaudRateChange: ((baud: number) => void) | null = null;
@ -574,17 +579,13 @@ function makeGpioRoutingClearHandler(boardId: string) {
function makePinPullHandler(boardId: string) {
return (gpio: number, pull: 0 | 1 | 2) => {
// Record the internal pull so the netlist stamps a weak resistor
// (vcc_rail for pull-up, GND for pull-down) and request a re-solve. The
// digital read itself is driven from the solved circuit by
// connectDigitalInputsToMcu — we deliberately do NOT seed the pin directly
// here, because that would bypass the real wiring and re-introduce the
// "mis-wired button still works" bug.
pinManagerMap.get(boardId)?.setPinPull(gpio, pull);
// Drive the digital input to the pull's idle level so the firmware's
// digitalRead reflects INPUT_PULLUP / INPUT_PULLDOWN. QEMU does not model
// the MCU's internal pull on the GPIO input register, and the part-level
// HIGH seed (BasicParts) is sent at component-attach — before the
// several-second QEMU boot finishes — so it's lost and the pin reads LOW.
// This fires when the guest actually programs the pull (inside pinMode,
// post-boot), so it sticks. A real button press/release overrides it after.
if (pull !== 0) getEsp32Bridge(boardId)?.sendPinEvent(gpio, pull === 1);
// The pull also feeds the SPICE netlist (a weak resistor); re-solve so the
// electrical view matches.
requestElectricalResolve();
};
}