velxio/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts

105 lines
5.1 KiB
TypeScript

/**
* 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';
import { isStm32BoardKind } from '../../types/board';
import { stm32PinNameToLinear } from '../Stm32Bridge';
// 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, sourcedNets } = 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}:`;
// STM32 names pins PA0/PC13/… and its PinManager + setPinState key on the
// linear pin (port*16+pin); every other board uses plain GPIO numbers.
const isStm32 = isStm32BoardKind(board.boardKind);
for (const [key, net] of pinNetMap) {
if (!key.startsWith(prefix)) continue;
const pinName = key.slice(prefix.length);
const gpio = isStm32 ? stm32PinNameToLinear(pinName) : gpioFromPinName(pinName);
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,
// pull, button switch, divider, cross-board output, …). A net that is
// only floating (an event-driven part like a rotary encoder / keypad
// that has no SPICE model) is left to the part layer, which seeds the
// pin directly — otherwise its ~0 V floating read would force it LOW
// and fight the part. This is what makes it safe to enable
// spiceDrivenInputs on the AVR (which has many such part-driven pins).
if (!sourcedNets.has(net)) continue;
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();
};
}