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:
parent
d38ed04f85
commit
7fb2ee3de9
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue