diff --git a/backend/app/services/stm32_worker.py b/backend/app/services/stm32_worker.py index 68acf47c..07ae7283 100644 --- a/backend/app/services/stm32_worker.py +++ b/backend/app/services/stm32_worker.py @@ -83,9 +83,13 @@ _SPI_EVENT = ctypes.CFUNCTYPE(ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint16) _UART_TX = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint8) _RMT_EVENT = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint32) _GPIO_MATRIX_CB = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int) +_PULL_PIN = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int) class _CallbacksT(ctypes.Structure): + # MUST match, field-for-field, the callbacks_t struct in + # hw/arm/stm32_picsimlab.c. picsimlab_pull_pin is appended last (matching + # the C append) so the ESP32-compatible prefix stays byte-identical. _fields_ = [ ('picsimlab_write_pin', _WRITE_PIN), ('picsimlab_dir_pin', _DIR_PIN), @@ -95,6 +99,7 @@ class _CallbacksT(ctypes.Structure): ('pinmap', ctypes.c_void_p), ('picsimlab_rmt_event', _RMT_EVENT), ('picsimlab_gpio_matrix_cb', _GPIO_MATRIX_CB), + ('picsimlab_pull_pin', _PULL_PIN), ] @@ -222,6 +227,16 @@ def main() -> None: return _emit({'type': 'gpio_dir', 'pin': int(pin), 'dir': int(direction)}) + def _on_pull_change(pin, pull): + # Internal pull of an INPUT pin changed: 0=none, 1=up, 2=down. The + # frontend stamps the matching weak resistor in its circuit netlist so + # digitalRead() of a button-to-GND on an INPUT_PULLUP pin reads idle + # HIGH / pressed LOW. QEMU already de-dupes (only fires on a real + # change), so forward verbatim. `pin` is the linear port*16+pin index. + if _stopped.is_set() or pin < 0: + return + _emit({'type': 'gpio_pull', 'pin': int(pin), 'pull': int(pull)}) + def _on_uart_tx(uart_id, byte_val): if _stopped.is_set(): return @@ -291,6 +306,7 @@ def main() -> None: pinmap = ctypes.cast(_PINMAP, ctypes.c_void_p).value, picsimlab_rmt_event = _RMT_EVENT(_on_rmt_event), picsimlab_gpio_matrix_cb = _GPIO_MATRIX_CB(_on_gpio_matrix), + picsimlab_pull_pin = _PULL_PIN(_on_pull_change), ) lib.qemu_picsimlab_register_callbacks(ctypes.byref(_cbs_ref)) diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index 8a666b84..3aa07c15 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -6,6 +6,7 @@ import type { I2CDevice } from './I2CBusManager'; import { bootromB1 } from './rp2040-bootrom'; import { loadUF2, loadUserFiles, getFirmware } from './MicroPythonLoader'; import { type PioPeripheral, createPioPeripheral } from './PioPeripheral'; +import { requestElectricalResolve } from './spice/electricalResolveHook'; /** * RP2040Simulator — Emulates Raspberry Pi Pico (RP2040) using rp2040js @@ -145,6 +146,14 @@ export class IdleSpinDetector { export type RP2040I2CDevice = I2CDevice; export class RP2040Simulator { + // Drive digital INPUT pins from the solved circuit (connectDigitalInputsToMcu) + // instead of the legacy part-seed, so digitalRead() reflects the REAL wiring: + // a pin tied to a rail reads that rail, a button-to-GND on an INPUT_PULLUP pin + // reads idle-HIGH / pressed-LOW. The internal pull is surfaced from the pad + // config in the GPIO listener below (see setupGpioListeners). Mirrors AVR / + // ESP32. Event-driven parts with no SPICE model (rotary encoder, keypad) are + // protected by the `sourcedNets` gate inside the connector. + readonly spiceDrivenInputs = true; private rp2040: RP2040 | null = null; private running = false; private animationFrame: number | null = null; @@ -768,7 +777,30 @@ export class RP2040Simulator { if (!gpio) continue; const unsub = gpio.addListener((state: GPIOPinState) => { - const isHigh = state === GPIOPinState.High || state === GPIOPinState.InputPullUp; + // rp2040js reports the pin's MODE here, not its external value: Low/High + // mean the MCU is driving the pad (outputEnable), while Input/ + // InputPullUp/InputPullDown/InputBusKeeper mean it's a high-Z input + // whose pad pull config is encoded in the state. The listener only fires + // on a mode/pull change (an external value change via setInputValue does + // not alter `value` for an input pin), so we can split cleanly. + if (state >= GPIOPinState.Input) { + // INPUT pin. Surface the internal pull so NetlistBuilder stamps the + // weak resistor; the actual logic level is injected from the SPICE + // solve by connectDigitalInputsToMcu. We do NOT mark the pin as an MCU + // output (triggerPinChange 'mcu'), or the connector would skip it. + const pull = + state === GPIOPinState.InputPullUp ? 1 : state === GPIOPinState.InputPullDown ? 2 : 0; + this.pinManager.setPinPull(pin, pull); + // Seed the idle level the pull alone would produce (rp2040js does not + // auto-apply the pad pull to the readable input register). The + // connector overrides this whenever the pin's net is actually sourced + // (rail / button / divider); an unwired pulled input keeps this level. + if (pull === 1) gpio.setInputValue(true); + else if (pull === 2) gpio.setInputValue(false); + requestElectricalResolve(); + return; + } + const isHigh = state === GPIOPinState.High; this.pinManager.triggerPinChange(pin, isHigh, 'mcu'); if (this.onPinChangeWithTime && this.rp2040) { // IClock interface exposes `nanos` (not `timeUs`) diff --git a/frontend/src/simulation/Stm32Bridge.ts b/frontend/src/simulation/Stm32Bridge.ts index f9a5bb11..fb9ca5a0 100644 --- a/frontend/src/simulation/Stm32Bridge.ts +++ b/frontend/src/simulation/Stm32Bridge.ts @@ -16,6 +16,7 @@ * { type: 'serial_output', data: { data: string, uart?: number } } * { type: 'gpio_change', data: { pin: number, state: 0|1 } } // linear pin (port*16+pin) * { 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: 'system', data: { event: string, ... } } * { type: 'error', data: { message: string } } * @@ -82,6 +83,10 @@ export class Stm32Bridge { onPinChange: ((gpioPin: number, state: boolean) => void) | null = null; onPinChangeWithTime: ((gpioPin: number, state: boolean, timeMs: number) => void) | null = null; onPinDir: ((gpioPin: number, dir: 0 | 1) => void) | null = null; + /** Internal pull the guest programmed for an INPUT pin (from PUPDR / CRL+ODR): + * 0 = none, 1 = pull-up, 2 = pull-down. gpioPin is the linear pin. The store + * records it so the netlist stamps the weak resistor (mirrors ESP32). */ + onPinPull: ((gpioPin: number, pull: 0 | 1 | 2) => void) | null = null; onConnected: (() => void) | null = null; onDisconnected: (() => void) | null = null; onError: ((msg: string) => void) | null = null; @@ -166,6 +171,10 @@ export class Stm32Bridge { this.onPinDir?.(msg.data.pin as number, msg.data.dir as 0 | 1); break; } + case 'gpio_pull': { + this.onPinPull?.(msg.data.pin as number, msg.data.pull as 0 | 1 | 2); + break; + } case 'system': { const evt = msg.data.event as string; if (evt === 'crash') this.onCrash?.(msg.data); diff --git a/frontend/src/simulation/spice/collectPinStates.ts b/frontend/src/simulation/spice/collectPinStates.ts index 64675e97..6981463f 100644 --- a/frontend/src/simulation/spice/collectPinStates.ts +++ b/frontend/src/simulation/spice/collectPinStates.ts @@ -10,6 +10,8 @@ import { getBoardPinManager } from '../../store/useSimulatorStore'; import type { PinSourceState } from './types'; import type { BoardKind } from '../../types/board'; +import { isStm32BoardKind } from '../../types/board'; +import { stm32PinNameToLinear } from '../Stm32Bridge'; import { BOARD_PIN_GROUPS } from './boardPinGroups'; /** @@ -73,6 +75,25 @@ export function collectPinStates( const outputPins = pm.getOutputPins(); + // STM32 names pins PA0/PC13 and keys its PinManager on the linear pin + // (port*16+pin). It runs in backend QEMU, where its OUTPUT pins are surfaced + // to the canvas via the part layer (not SPICE), so here we only contribute + // the INPUT internal pull (reported by the worker's gpio_pull) — enough for + // NetlistBuilder to stamp the weak resistor so an INPUT_PULLUP button-to-GND + // solves to idle-HIGH / pressed-LOW. connectDigitalInputsToMcu then drives + // the guest IDR from the solve. Leaving outputs out keeps STM32 LED rendering + // exactly as before. + const isStm32 = isStm32BoardKind(boardKind); + if (isStm32) { + for (const pinName of pinNames) { + const linear = stm32PinNameToLinear(pinName); + if (linear < 0 || outputPins.has(linear)) continue; + const pull = pm.getPinPull(linear); + if (pull !== 0) result[pinName] = { type: 'input', pull }; + } + return result; + } + for (const pinName of pinNames) { const arduinoPin = pinNameToArduinoPin(pinName, boardKind); if (arduinoPin < 0) continue; diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index ff31dba4..99b781f0 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -599,6 +599,13 @@ function makePinPullHandler(boardId: string) { // channel through the `.spi` adapter — identical surface to Esp32BridgeShim, // minus the ESP32-only WiFi / proxy-resync machinery. ────────────────────── class Stm32BridgeShim { + // Drive digital INPUT pins from the solved circuit (connectDigitalInputsToMcu) + // instead of the legacy part-seed, so digitalRead() reflects the REAL wiring. + // The internal pull is reported by the backend QEMU worker via the bridge's + // `gpio_pull` message (wired to makePinPullHandler in the store). Mirrors AVR + // / RP2040 / ESP32; event-driven parts with no SPICE model are protected by + // the `sourcedNets` gate in the connector. + readonly spiceDrivenInputs = true; pinManager: PinManager; onSerialData: ((ch: string) => void) | null = null; onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null; @@ -1175,6 +1182,9 @@ export const useSimulatorStore = create((set, get) => { } }; bridge.onPinChangeWithTime = getOscilloscopeCallback(id); + // Record the guest's internal pull so NetlistBuilder stamps the weak + // resistor; the connector then drives the pin from the solved circuit. + bridge.onPinPull = makePinPullHandler(id); bridge.onDisconnected = () => { set((s) => { const boards = s.boards.map((b) => (b.id === id ? { ...b, running: false } : b));