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.
This commit is contained in:
David Montero 2026-06-24 03:32:17 +02:00
parent 03339a132a
commit d8b6c77335
3 changed files with 74 additions and 16 deletions

View File

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

View File

@ -222,6 +222,49 @@ describe('PASSIVE_PRESETS — preset variants share their base mapper', () => {
});
});
describe('pushbutton — 4-pin tactile model', () => {
const lookup = (wired: Record<string, string>) => (pin: string) => wired[pin] ?? null;
const emit = (wired: Record<string, string>, 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

View File

@ -639,14 +639,29 @@ const MAPPERS: Record<string, Mapper> = {
};
},
// 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');