sim: drive RP2040 + STM32 digital inputs from the real circuit

Extend the spice-driven input path (already live for AVR/ESP32) to RP2040 and
STM32 so digitalRead() of an INPUT pin reflects the actual wiring: a pin tied
to a rail reads that rail, and an INPUT_PULLUP button-to-GND reads idle-HIGH /
pressed-LOW instead of floating or inverted.

RP2040 (rp2040js, frontend-only): the GPIO listener now splits input vs output
mode. Input pins report their pad pull (InputPullUp/Down) via setPinPull and
seed the pull's idle level (rp2040js does not auto-apply the pad pull to the
readable input register); the SPICE solve then overrides via connectDigital-
InputsToMcu when the net is actually sourced. Output pins drive as before.
spiceDrivenInputs = true.

STM32 (backend QEMU): the worker now forwards a new gpio_pull event (from the
libqemu-arm picsimlab_pull_pin callback) so the netlist stamps the matching
weak resistor; Stm32Bridge surfaces it, Stm32BridgeShim opts into
spiceDrivenInputs, and collectPinStates maps PA0/PC13 names to the linear pin
so the pull is read. STM32 outputs stay on the part layer (unchanged).

Event-driven parts with no SPICE model (rotary encoder, keypad) remain
protected by the existing sourcedNets gate in the connector.
This commit is contained in:
David Montero 2026-06-26 22:05:24 +02:00
parent 48099c0bda
commit ed132afb91
5 changed files with 89 additions and 1 deletions

View File

@ -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))

View File

@ -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`)

View File

@ -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);

View File

@ -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;

View File

@ -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<SimulatorState>((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));