diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index 79134706..f0476da2 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -24,6 +24,7 @@ stdout : JSON event lines (one per line, flushed immediately) {"type": "system", "event": "reboot", "count": N} {"type": "gpio_change", "pin": N, "state": V} {"type": "gpio_dir", "pin": N, "dir": V} + {"type": "gpio_pull", "pin": N, "pull": V} # 0=none 1=up 2=down {"type": "uart_tx", "uart": N, "byte": V} {"type": "ledc_duty", "channel": N, "duty_pct": F} {"type": "rmt_event", "channel": N, ...} @@ -521,6 +522,38 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) except Exception: pass + # GPIO pull-up / pull-down state, keyed by GPIO number → 0=none 1=up 2=down. + # Diff-based like the SignalRouter snapshot so we only emit real changes. + _pull_state: dict[int, int] = {} + + def _refresh_pin_pulls() -> None: + """Scan IO_MUX FUN_WPU/FUN_WPD bits and emit `gpio_pull` on change. + + The ESP32's internal pull resistors are configured per pad in the + IO_MUX register: FUN_WPU (bit 8) enables the pull-up, FUN_WPD + (bit 7) the pull-down. The frontend needs this to model + INPUT_PULLUP / INPUT_PULLDOWN — without it an input wired to a + button-to-GND floats to 0 V in the SPICE solve and reads LOW even + at idle, so the canonical active-low button never works. The + `muxgpios[40]` array is exposed read-only via get_internals(3) and + is indexed by GPIO number. Idempotent + diff-based; runs every + 100 ms from the LEDC poll thread (off the QEMU iothread, so the + _emit here can't stall the guest the way _on_gpio_matrix would). + """ + try: + iomux_ptr = lib.qemu_picsimlab_get_internals(3) # QEMU_INTERNAL_IOMUX_GPIOS + if not iomux_ptr: + return + mux = (ctypes.c_uint32 * 40).from_address(iomux_ptr) + for gpio_pin in range(40): + reg = int(mux[gpio_pin]) + pull = 1 if (reg >> 8) & 1 else (2 if (reg >> 7) & 1 else 0) + if _pull_state.get(gpio_pin, 0) != pull: + _pull_state[gpio_pin] = pull + _emit({'type': 'gpio_pull', 'pin': gpio_pin, 'pull': pull}) + except Exception: + pass + # Sensor state: gpio_pin → {type, properties..., saw_low, responding} _sensors: dict[int, dict] = {} _sensors_lock = threading.Lock() @@ -1631,6 +1664,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) # `gpio_routing` events so frontend's SignalRouter is # in sync before duty updates land. _refresh_signal_routing() + # Reconcile internal pull-up/down config (INPUT_PULLUP etc.) + # so the frontend's netlist can add the matching resistor. + _refresh_pin_pulls() for ch in range(16): duty_pct = float(arr[ch]) if abs(duty_pct - _last_duty[ch]) < 0.01: diff --git a/frontend/src/__tests__/netlist-builder.test.ts b/frontend/src/__tests__/netlist-builder.test.ts index 1efb5684..a7198b4e 100644 --- a/frontend/src/__tests__/netlist-builder.test.ts +++ b/frontend/src/__tests__/netlist-builder.test.ts @@ -244,3 +244,75 @@ describe('NetlistBuilder — simple cases', () => { expect(netlist).toMatch(/\.end/); }); }); + +describe('NetlistBuilder — ESP32 internal pull-up (INPUT_PULLUP)', () => { + // GPIO4 set INPUT_PULLUP, with a pushbutton from GPIO4 (1.l) to GND (2.l). + // This is the canonical active-low button. Without the internal pull the + // input floats to 0 V and reads LOW even at idle; the stamped 45k pull-up + // to the 3.3 V rail makes idle read HIGH and a press read LOW. + const build = (pressed: boolean) => + buildNetlist({ + components: [{ id: 'btn', metadataId: 'pushbutton', properties: { pressed } }], + wires: [ + { + id: 'w1', + start: { componentId: 'esp32', pinName: '4' }, + end: { componentId: 'btn', pinName: '1.l' }, + }, + { + id: 'w2', + start: { componentId: 'btn', pinName: '2.l' }, + end: { componentId: 'esp32', pinName: 'GND2' }, + }, + ], + boards: [ + { + id: 'esp32', + vcc: 3.3, + pins: { '4': { type: 'input', pull: 1 } }, + groundPinNames: ['GND2'], + }, + ], + analysis: { kind: 'op' as const }, + }); + + it('stamps the pull resistor + rail source', () => { + const { netlist } = build(false); + expect(netlist).toMatch(/R_pull_esp32_4 \S+ vcc_rail 45000/); + // The rail source must exist even though no wire references VCC. + expect(netlist).toMatch(/V_VCC_RAIL vcc_rail 0 DC 3\.3/); + }); + + it('idles HIGH (~3.3 V) when the button is open', { timeout: 30_000 }, async () => { + const { netlist, pinNetMap } = build(false); + const net = pinNetMap.get('esp32:4')!; + const result = await runNetlist(netlist); + expect(result.dcValue(`v(${net})`)).toBeCloseTo(3.3, 1); + }); + + it('reads LOW (~0 V) when the button is pressed', { timeout: 30_000 }, async () => { + const { netlist, pinNetMap } = build(true); + const net = pinNetMap.get('esp32:4')!; + const result = await runNetlist(netlist); + expect(result.dcValue(`v(${net})`)).toBeCloseTo(0, 1); + }); + + it('pull-down (INPUT_PULLDOWN) ties the idle level to 0 V', { timeout: 30_000 }, async () => { + const { netlist, pinNetMap } = buildNetlist({ + components: [{ id: 'r', metadataId: 'resistor', properties: { value: '1Meg' } }], + wires: [ + { + id: 'w1', + start: { componentId: 'esp32', pinName: '5' }, + end: { componentId: 'r', pinName: '1' }, + }, + ], + boards: [{ id: 'esp32', vcc: 3.3, pins: { '5': { type: 'input', pull: 2 } } }], + analysis: { kind: 'op' as const }, + }); + expect(netlist).toMatch(/R_pull_esp32_5 \S+ 0 45000/); + const net = pinNetMap.get('esp32:5')!; + const result = await runNetlist(netlist); + expect(result.dcValue(`v(${net})`)).toBeCloseTo(0, 1); + }); +}); diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 53a53499..d9dcea87 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -22,6 +22,7 @@ * { type: 'serial_output', data: { data: string, uart?: number } } * { type: 'gpio_change', data: { pin: number, state: 0 | 1 } } * { type: 'gpio_dir', data: { pin: number, dir: 0 | 1 } } + * { type: 'gpio_pull', data: { pin: number, pull: 0 | 1 | 2 } } // 0=none 1=up 2=down * { type: 'ledc_duty', data: { channel: number, duty_pct: number } } * { type: 'gpio_routing', data: { gpio: number, signal_id: number } } * { type: 'gpio_routing_clear', data: { gpio: number } } @@ -134,6 +135,11 @@ export class Esp32Bridge { */ onPinChangeWithTime: ((gpioPin: number, state: boolean, timeMs: number) => void) | null = null; onPinDir: ((gpioPin: number, dir: 0 | 1) => void) | null = null; + /** Internal pull config the guest programmed into IO_MUX (INPUT_PULLUP / + * INPUT_PULLDOWN). 0 = none, 1 = pull-up, 2 = pull-down. The frontend + * netlist adds the matching weak resistor so idle inputs read the right + * level (real ESP32 internal pulls aren't otherwise visible to SPICE). */ + onPinPull: ((gpioPin: number, pull: 0 | 1 | 2) => void) | null = null; /** * Override baud rate used to space synthesized UART bits. QEMU * transmits bytes "instantly" so the backend doesn't surface a real @@ -405,6 +411,12 @@ export class Esp32Bridge { this.onPinDir?.(pin, dir); break; } + case 'gpio_pull': { + const pin = msg.data.pin as number; + const pull = msg.data.pull as 0 | 1 | 2; + this.onPinPull?.(pin, pull); + break; + } case 'ledc_duty': { this.onLedcDuty?.(msg.data as unknown as LedcDuty); break; diff --git a/frontend/src/simulation/PinManager.ts b/frontend/src/simulation/PinManager.ts index 15a68714..30b8c581 100644 --- a/frontend/src/simulation/PinManager.ts +++ b/frontend/src/simulation/PinManager.ts @@ -36,6 +36,11 @@ export class PinManager { // (NTC + divider on A0, photoresistor, etc.) don't get clamped to // the MCU's idle V-source. private outputPins: Set = new Set(); + // Internal pull config the MCU programmed per pin: 0=none, 1=up, 2=down. + // Used by the SPICE collector to add a weak pull resistor so INPUT_PULLUP + // inputs read the right idle level (the ESP32's internal pulls live inside + // QEMU and are otherwise invisible to the netlist). + private pinPulls: Map = new Map(); // ── Digital pin API ────────────────────────────────────────────────────── @@ -141,6 +146,21 @@ export class PinManager { return this.outputPins; } + /** + * Record the internal pull the MCU programmed for a pin (from the guest's + * IO_MUX / pad config): 0 = none, 1 = pull-up, 2 = pull-down. The SPICE + * collector reads this back via `getPinPull` to stamp a weak resistor. + */ + setPinPull(pin: number, pull: 0 | 1 | 2): void { + if (pull === 0) this.pinPulls.delete(pin); + else this.pinPulls.set(pin, pull); + } + + /** Internal pull config for a pin: 0 = none, 1 = pull-up, 2 = pull-down. */ + getPinPull(pin: number): 0 | 1 | 2 { + return this.pinPulls.get(pin) ?? 0; + } + /** * Drop only the MCU-output classification (SPICE side). Used by * paths that need to forget which pins were driven this session @@ -168,6 +188,7 @@ export class PinManager { } this.pinStates.clear(); this.outputPins.clear(); + this.pinPulls.clear(); for (const pin of wereHigh) { const callbacks = this.listeners.get(pin); if (callbacks) { diff --git a/frontend/src/simulation/spice/NetlistBuilder.ts b/frontend/src/simulation/spice/NetlistBuilder.ts index a040de18..2551c68c 100644 --- a/frontend/src/simulation/spice/NetlistBuilder.ts +++ b/frontend/src/simulation/spice/NetlistBuilder.ts @@ -152,10 +152,27 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult { // hyphenated source names — making MCU pin transitions stop propagating // after the very first solve. So MixedModeScheduler.onMcuPinChange must // build the same sanitized name we emit here. See sanitizeSpiceId(). + // Set when an internal pull-up is stamped to vcc_rail but no wired pin + // referenced VCC — we must still emit the rail source (step 6) or the + // pull-up would hang off a dead node and the input would read LOW anyway. + let vccRailNeeded = false; for (const board of boards) { for (const [pinName, state] of Object.entries(board.pins)) { - if (state.type === 'input') continue; // don't drive the pin const net = netLookup(board.id, pinName); + if (state.type === 'input') { + // Internal pull-up / pull-down (INPUT_PULLUP / INPUT_PULLDOWN): a weak + // resistor to the rail so the idle level is correct. 45 kΩ matches the + // ESP32 internal pull and is weak enough that any real external driver + // or pull (<= 10 kΩ) dominates. An input is never driven otherwise. + if (state.pull && net && net !== '0' && net !== 'vcc_rail') { + const rail = state.pull === 1 ? 'vcc_rail' : '0'; + if (state.pull === 1) vccRailNeeded = true; + cards.push( + `R_pull_${sanitizeSpiceId(board.id)}_${sanitizeSpiceId(pinName)} ${net} ${rail} 45000`, + ); + } + continue; + } if (!net) continue; if (net === '0' || net === 'vcc_rail') continue; // already served const v = state.type === 'digital' ? state.v : state.duty * board.vcc; @@ -163,8 +180,8 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult { } } - // ── 6. Vcc rail source (if any pin referenced it) ───────────────────────── - if (hasNet(netNames, 'vcc_rail')) { + // ── 6. Vcc rail source (if any pin referenced it, or a pull-up needs it) ── + if (hasNet(netNames, 'vcc_rail') || vccRailNeeded) { cards.unshift(`V_VCC_RAIL vcc_rail 0 DC ${dominantVcc}`); } diff --git a/frontend/src/simulation/spice/collectPinStates.ts b/frontend/src/simulation/spice/collectPinStates.ts index 90fbdf77..64675e97 100644 --- a/frontend/src/simulation/spice/collectPinStates.ts +++ b/frontend/src/simulation/spice/collectPinStates.ts @@ -92,11 +92,17 @@ export function collectPinStates( type: 'digital', v: pm.getPinState(arduinoPin) ? vcc : 0, }; + } else { + // Not driven by the MCU. If the firmware enabled an internal pull + // (INPUT_PULLUP / INPUT_PULLDOWN), surface it so NetlistBuilder can + // stamp the weak resistor — without it a button-to-GND input floats + // to 0 V and reads LOW even at idle, so active-low buttons never work. + // For pull-less inputs leave the net free: external components (sensor + // divider, pull-up, button, etc.) drive the SPICE node, and an + // unconditional 0 V source would short out any analog sensor on the pin. + const pull = pm.getPinPull(arduinoPin); + if (pull !== 0) result[pinName] = { type: 'input', pull }; } - // else: leave the net free — external components (sensor divider, - // pull-up, button, etc.) drive the SPICE node. Without this guard, - // an unconditional V-source at 0 V would short-circuit any analog - // sensor on the pin and analogRead() would always return 0. } return result; } diff --git a/frontend/src/simulation/spice/types.ts b/frontend/src/simulation/spice/types.ts index 34fd357d..5d5930f8 100644 --- a/frontend/src/simulation/spice/types.ts +++ b/frontend/src/simulation/spice/types.ts @@ -53,7 +53,11 @@ export interface BoardForSpice { export type PinSourceState = | { type: 'digital'; v: 0 | 5 | 3.3 | number } | { type: 'pwm'; duty: number } - | { type: 'input' }; + // An input pin. `pull` carries the MCU's internal pull config (from + // INPUT_PULLUP / INPUT_PULLDOWN): 0/undefined = none, 1 = pull-up, + // 2 = pull-down. The NetlistBuilder stamps a weak resistor to the rail so + // the idle level is correct; without it a button-to-GND input reads LOW. + | { type: 'input'; pull?: 0 | 1 | 2 }; /** Electrical analyses the solver can perform. */ export type AnalysisMode = diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index f3bb12ca..2e4990af 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -5,6 +5,7 @@ import { RiscVSimulator } from '../simulation/RiscVSimulator'; import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator'; import { PinManager } from '../simulation/PinManager'; import { SignalRouter } from '../simulation/SignalRouter'; +import { requestElectricalResolve } from '../simulation/spice/electricalResolveHook'; import { ledcSignalForChannel } from '../simulation/esp32-signals'; import { VirtualDS1307, @@ -571,6 +572,17 @@ function makeGpioRoutingClearHandler(boardId: string) { }; } +function makePinPullHandler(boardId: string) { + return (gpio: number, pull: 0 | 1 | 2) => { + pinManagerMap.get(boardId)?.setPinPull(gpio, pull); + // The pull adds/removes a netlist resistor (structural change), so a full + // re-solve is needed. It also matters at idle: nothing else triggers a + // solve while the firmware just polls digitalRead, so without this an + // INPUT_PULLUP input stays stuck at the floating 0 V the last solve found. + requestElectricalResolve(); + }; +} + // ── Lightweight shim wrapping Stm32Bridge so PartSimulationRegistry parts // (I2C displays, sensors, SPI panels) attach to an STM32 board the same way // they attach to ESP32. Like the STM32 firmware itself, every device model @@ -1100,6 +1112,7 @@ export const useSimulatorStore = create((set, get) => { bridge.onLedcDuty = makeLedcDutyHandler(id); bridge.onGpioRouting = makeGpioRoutingHandler(id); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(id); + bridge.onPinPull = makePinPullHandler(id); bridge.onWs2812Update = (channel, pixels) => { // Forward WS2812 pixel data to any DOM element with id=`ws2812-{id}-{channel}` // (set by NeoPixel components rendered in SimulatorCanvas). @@ -2034,6 +2047,7 @@ export const useSimulatorStore = create((set, get) => { bridge.onLedcDuty = makeLedcDutyHandler(boardId); bridge.onGpioRouting = makeGpioRoutingHandler(boardId); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId); + bridge.onPinPull = makePinPullHandler(boardId); bridge.onWs2812Update = (channel, pixels) => { const eventTarget = document.getElementById(`ws2812-${boardId}-${channel}`); if (eventTarget) { @@ -2146,6 +2160,7 @@ export const useSimulatorStore = create((set, get) => { bridge.onLedcDuty = makeLedcDutyHandler(boardId); bridge.onGpioRouting = makeGpioRoutingHandler(boardId); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId); + bridge.onPinPull = makePinPullHandler(boardId); bridge.onWs2812Update = (channel, pixels) => { const eventTarget = document.getElementById(`ws2812-${boardId}-${channel}`); if (eventTarget) {