From d8b6c77335ff45632e1a1dd620f3818ef55629a6 Mon Sep 17 00:00:00 2001 From: David Montero Date: Wed, 24 Jun 2026 03:32:17 +0200 Subject: [PATCH] fix(spice): model pushbutton as a real 4-pin tactile switch The pushbutton was modelled as a switch between only 1.l and 2.l; the other two legs (1.r, 2.r) connected to nothing. Wiring GND/GPIO to those legs silently produced a dead button, and the failure was invisible. Model it like hardware: 1.l is internally shorted to 1.r and 2.l to 2.r, and pressing bridges terminal 1 to terminal 2. Wiring to any leg now works, and putting GPIO and GND on the same terminal is a dead short, exactly as on a real tactile switch. Back-compat A/B variant preserved. --- .../examples-netlist-snapshot.test.ts.snap | 18 ++++---- .../src/__tests__/component-to-spice.test.ts | 43 +++++++++++++++++++ .../src/simulation/spice/componentToSpice.ts | 29 ++++++++++--- 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/frontend/src/__tests__/__snapshots__/examples-netlist-snapshot.test.ts.snap b/frontend/src/__tests__/__snapshots__/examples-netlist-snapshot.test.ts.snap index eb2b7294..ce5a457e 100644 --- a/frontend/src/__tests__/__snapshots__/examples-netlist-snapshot.test.ts.snap +++ b/frontend/src/__tests__/__snapshots__/examples-netlist-snapshot.test.ts.snap @@ -92,8 +92,8 @@ exports[`netlist snapshot — 100-days (49 examples) > 100d-esp32-ir-sensor-tele exports[`netlist snapshot — 100-days (49 examples) > 100d-esp32-oled-smart-ui-eyes-animation-time-and-weather-micropython 1`] = ` "* Velxio circuit -R_btn-next n4 n0 1000000000 -R_btn-sel n4 n1 1000000000 +R_btn-next_sw n4 n0 1000000000 +R_btn-sel_sw n4 n1 1000000000 R_autopull_n0 n0 0 100Meg R_autopull_n1 n1 0 100Meg R_autopull_n2 n2 0 100Meg @@ -1166,9 +1166,9 @@ R_autopull_n6 n6 0 100Meg exports[`netlist snapshot — circuits (41 examples) > full-adder 1`] = ` "* Velxio circuit -R_bA n0 n5 1000000000 -R_bB n1 n5 1000000000 -R_bCin n2 n5 1000000000 +R_bA_sw n0 n5 1000000000 +R_bB_sw n1 n5 1000000000 +R_bCin_sw n2 n5 1000000000 R_rSum n3 n7 220 R_rCout n4 n6 220 V_sumLed_sense n7 sumLed_sense_mid DC 0 @@ -1393,8 +1393,8 @@ R_autopull_n10 n10 0 100Meg exports[`netlist snapshot — circuits (41 examples) > nand-sr-latch 1`] = ` "* Velxio circuit -R_setBtn n5 n6 1000000000 -R_rstBtn n4 n6 1000000000 +R_setBtn_sw n5 n6 1000000000 +R_rstBtn_sw n4 n6 1000000000 R_rpuS n0 n5 10000 R_rpuR n0 n4 10000 B_g1 n1 0 V = 5 * (1 - u(V(n5)-2.5) * u(V(n2)-2.5)) @@ -1905,8 +1905,8 @@ R_autopull_n3 n3 0 100Meg exports[`netlist snapshot — circuits (41 examples) > xor-toggle-detector 1`] = ` "* Velxio circuit -R_sw1 n0 n5 1000000000 -R_sw2 n1 n5 1000000000 +R_sw1_sw n0 n5 1000000000 +R_sw2_sw n1 n5 1000000000 B_u1 n6 0 V = 5 * (u(V(n2)-2.5) + u(V(n3)-2.5) - 2*u(V(n2)-2.5)*u(V(n3)-2.5)) R_u1_load n6 0 1Meg R_rl n6 n4 220 diff --git a/frontend/src/__tests__/component-to-spice.test.ts b/frontend/src/__tests__/component-to-spice.test.ts index 1f0b37c1..c3338ab2 100644 --- a/frontend/src/__tests__/component-to-spice.test.ts +++ b/frontend/src/__tests__/component-to-spice.test.ts @@ -222,6 +222,49 @@ describe('PASSIVE_PRESETS — preset variants share their base mapper', () => { }); }); +describe('pushbutton — 4-pin tactile model', () => { + const lookup = (wired: Record) => (pin: string) => wired[pin] ?? null; + const emit = (wired: Record, pressed = false) => + componentToSpice( + { id: 'b1', metadataId: 'pushbutton', properties: { pressed } }, + lookup(wired), + { vcc: 3.3 }, + ); + + it('switches between terminal 1 and 2 wired on the .l legs', () => { + const e = emit({ '1.l': 'gpio', '2.l': 'gnd' }); + expect(e!.cards).toEqual(['R_b1_sw gpio gnd 1000000000']); + }); + + it('works wired on the .r legs too (terminal legs are interchangeable)', () => { + const e = emit({ '1.r': 'gpio', '2.r': 'gnd' }); + expect(e!.cards).toEqual(['R_b1_sw gpio gnd 1000000000']); + }); + + it('pressing closes the switch (low resistance)', () => { + const e = emit({ '1.l': 'gpio', '2.l': 'gnd' }, true); + expect(e!.cards).toEqual(['R_b1_sw gpio gnd 0.01']); + }); + + it('GPIO and GND on the SAME terminal is a dead short, like real hardware', () => { + // The classic miswire: GND lands on 1.r while the GPIO is on 1.l. Both legs + // belong to terminal 1, so they are internally shorted — the pin can never + // read anything but the GND it is tied to, and there is no switch path. + const e = emit({ '1.l': 'gpio', '1.r': 'gnd' }); + expect(e!.cards).toEqual(['R_b1_t1 gpio gnd 0.01']); + expect(e!.cards.some((c) => c.includes('_sw'))).toBe(false); + }); + + it('back-compat: 2-pin A/B variant still emits a switch', () => { + const e = emit({ A: 'n1', B: 'n2' }); + expect(e!.cards).toEqual(['R_b1_sw n1 n2 1000000000']); + }); + + it('returns null when nothing is wired', () => { + expect(emit({})).toBeNull(); + }); +}); + // Mappers whose output depends on live runtime state rather than the static // component (pins + properties) a fixture can describe. `custom-chip` emits its // SPICE sources from getChipDrivenPins(comp.id) — the chip's currently-driven diff --git a/frontend/src/simulation/spice/componentToSpice.ts b/frontend/src/simulation/spice/componentToSpice.ts index 684b03e7..e0fb3b7e 100644 --- a/frontend/src/simulation/spice/componentToSpice.ts +++ b/frontend/src/simulation/spice/componentToSpice.ts @@ -639,14 +639,29 @@ const MAPPERS: Record = { }; }, - // Switch / pushbutton + // Switch / pushbutton — a real 4-pin tactile switch. The two legs of each + // terminal are internally shorted (1.l is the same node as 1.r; 2.l as 2.r), + // and pressing bridges terminal 1 to terminal 2. Modelling BOTH legs (not + // just 1.l/2.l) means a user can wire GND/GPIO to any leg and it behaves like + // hardware — and wiring both a GPIO and GND to the SAME terminal is a dead + // short, exactly as on a real button. Back-compat: 2-pin variants expose A/B. pushbutton: (comp, netLookup) => { - const pins = twoPin(comp, netLookup, '1.l', '2.l'); - const alt = pins ?? twoPin(comp, netLookup, 'A', 'B'); - if (!alt) return null; - const pressed = Boolean(comp.properties.pressed); - const R = pressed ? 0.01 : 1e9; - return emitResistor(comp, alt, R); + const t1l = netLookup('1.l'); + const t1r = netLookup('1.r'); + const t2l = netLookup('2.l'); + const t2r = netLookup('2.r'); + const cards: string[] = []; + // Internal shorts between the two legs of a terminal, when both are wired. + if (t1l && t1r && t1l !== t1r) cards.push(`R_${comp.id}_t1 ${t1l} ${t1r} 0.01`); + if (t2l && t2r && t2l !== t2r) cards.push(`R_${comp.id}_t2 ${t2l} ${t2r} 0.01`); + // The switch itself: terminal 1 to terminal 2 (prefer the .l leg's net). + const term1 = t1l ?? t1r ?? netLookup('A'); + const term2 = t2l ?? t2r ?? netLookup('B'); + if (term1 && term2) { + const R = Boolean(comp.properties.pressed) ? 0.01 : 1e9; + cards.push(`R_${comp.id}_sw ${term1} ${term2} ${R}`); + } + return cards.length ? { cards, modelsUsed: new Set() } : null; }, 'slide-switch': (comp, netLookup) => { const pins = twoPin(comp, netLookup, '1', '2');