diff --git a/frontend/src/__tests__/netlist-builder.test.ts b/frontend/src/__tests__/netlist-builder.test.ts index a7198b4e..bcf41f58 100644 --- a/frontend/src/__tests__/netlist-builder.test.ts +++ b/frontend/src/__tests__/netlist-builder.test.ts @@ -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 }, diff --git a/frontend/src/simulation/parts/BasicParts.ts b/frontend/src/simulation/parts/BasicParts.ts index 3d428ea7..744b4141 100644 --- a/frontend/src/simulation/parts/BasicParts.ts +++ b/frontend/src/simulation/parts/BasicParts.ts @@ -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); }; diff --git a/frontend/src/simulation/spice/NetlistBuilder.ts b/frontend/src/simulation/spice/NetlistBuilder.ts index 2551c68c..be20a913 100644 --- a/frontend/src/simulation/spice/NetlistBuilder.ts +++ b/frontend/src/simulation/spice/NetlistBuilder.ts @@ -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. diff --git a/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts b/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts new file mode 100644 index 00000000..fb2effee --- /dev/null +++ b/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts @@ -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(); + + 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(); + 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(); + }; +} diff --git a/frontend/src/simulation/spice/start.ts b/frontend/src/simulation/spice/start.ts index 0db17391..0979c4ad 100644 --- a/frontend/src/simulation/spice/start.ts +++ b/frontend/src/simulation/spice/start.ts @@ -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(); }; diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 44ba15c7..ff31dba4 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -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(); }; }