feat(esp32): emulate internal pull-up/pull-down for GPIO inputs
INPUT_PULLUP / INPUT_PULLDOWN had no effect in simulation: the ESP32's internal pull resistors live inside QEMU and were invisible to the SPICE solver, so an input wired to a button-to-GND floated to 0 V and read LOW even at idle. The canonical active-low button never worked. Read the pull config straight out of the running guest: the IO_MUX register (FUN_PU bit 8 / FUN_PD bit 7) is already exposed read-only via qemu_picsimlab_get_internals(3), so no QEMU rebuild is needed. The worker scans it on the 100 ms poll thread and emits gpio_pull; the bridge feeds it to PinManager; the netlist stamps a weak 45k resistor to the rail so idle inputs read the correct level. 45k matches the real internal pull and is weak enough that any external driver/pull dominates. Verified with ngspice: idle ~3.3 V (HIGH), pressed ~0 V (LOW).
This commit is contained in:
parent
d8b6c77335
commit
df9e06c99a
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<number> = 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<number, 0 | 1 | 2> = 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) {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<SimulatorState>((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<SimulatorState>((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<SimulatorState>((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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue