diff --git a/frontend/src/__tests__/board-pin-mapping.test.ts b/frontend/src/__tests__/board-pin-mapping.test.ts index d3ad2595..025da60e 100644 --- a/frontend/src/__tests__/board-pin-mapping.test.ts +++ b/frontend/src/__tests__/board-pin-mapping.test.ts @@ -67,3 +67,133 @@ describe('m5stack-core header pins', () => { expect(boardPinToNumber('m5stack-core', 'NOPE')).toBeNull(); }); }); + +/** + * ESP32-family pads — no pad may resolve to null, and no supply pad may + * resolve to a GPIO. + * + * Two shapes of this bug shipped at once. `parseInt('3V', 10)` is 3, so the + * Wemos Lolin32 Lite's supply pad resolved to GPIO3 and the XIAO's 3V3/5V pads + * to GPIO3/GPIO5 — a wire to the rail drove a real pin. And a pad the mapper + * cannot name returns null, which means the wire connects to nothing at all: + * the DevKit V1's RX0/TX0 (the only route to GPIO1 and GPIO3, with no numeric + * twin), the Nano ESP32's RX0/TX1 (its map named them D0/D1, pads that do not + * exist on the element), and the S3 DevKitC's 40..48, rejected by a GPIO + * ceiling of 39 shared with the classic ESP32. + * + * Expected values cross-checked against the `target` field of the upstream + * wokwi-boards board.json for each board. + * + * The pad lists are copied verbatim from Esp32Element.ts (BOARD_CONFIGS) — + * same convention as pin-inspector-layout.test.ts, because vitest runs in the + * `node` environment and that module calls customElements.define at import. + * If a board's pads change, refresh the copy. + */ +const ESP32_FAMILY_PADS: Record = { + 'esp32': [ + 'EN', 'VN', 'VP', '34', '35', '32', '33', '25', '26', '27', '14', '12', '13', 'GND', + 'VIN', '3V3', 'GND2', '15', '2', '4', 'RX2', '16', 'TX2', '17', '5', '18', '19', + '21', 'RX0', 'TX0', '22', '23', + ], + 'esp32-s3': [ + '3V3.1', '3V3.2', 'RST', '4', '5', '6', '7', '15', '16', '17', '18', '8', '3', '46', + '9', '10', '11', '12', '13', '14', '5V', 'GND.1', 'GND.2', 'TX', 'RX', '1', '2', + '42', '41', '40', '39', '38', '37', '36', '35', '0', '45', '48', '47', '21', '20', + '19', 'GND.3', 'GND.4', + ], + 'esp32-c3': [ + 'GND.1', '3V3.1', '3V3.2', '2', '3', 'GND.2', 'RST', 'GND.3', '0', '1', '10', 'GND.4', + '5V.1', '5V.2', 'GND.5', 'GND.6', '19', '18', 'GND.7', '4', '5', '6', '7', 'GND.8', + '8', '9', 'GND.9', 'RX', 'TX', 'GND.10', + ], + 'esp32-devkit-c-v4': [ + '3V3', 'EN', 'VP', 'VN', '34', '35', '32', '33', '25', '26', '27', '14', '12', 'GND.1', + '13', 'D2', 'D3', 'CMD', '5V', 'GND.2', '23', '22', 'TX', 'RX', '21', 'GND.3', '19', + '18', '5', '17', '16', '4', '0', '2', '15', 'D1', 'D0', 'CLK', + ], + 'esp32-cam': [ + '5V.1', 'GND.1', '12', '13', '15', '14', '2', '4', '3V3', '16', '0', 'GND.2', 'VCC', + 'RX', 'TX', 'GND.3', + ], + 'wemos-lolin32-lite': [ + 'VP', 'VN', 'EN', 'GPIO34', 'GPIO35', 'GPIO32', 'GPIO33', 'GPIO25', 'GPIO26', 'GPIO27', + 'GPIO14', 'GPIO12', 'GND', 'GPIO13', 'GPIO15', 'GPIO2', 'GPIO0', 'GPIO4', 'GPIO16', + 'GPIO17', 'GPIO5', 'GPIO18', 'GPIO23', 'GPIO19', 'GPIO22', '3V', + ], + 'xiao-esp32-s3': ['D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8', 'D9', 'D10', '3V3', 'GND', '5V'], + 'arduino-nano-esp32': [ + 'D12', 'D11', 'D10', 'D9', 'D8', 'D7', 'D6', 'D5', 'D4', 'D3', 'D2', 'GND.1', 'RST', + 'RX0', 'TX1', 'D13', '3V3', 'B0', 'A0', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', + 'VBUS', 'B1', 'GND.2', 'VIN', + ], + 'xiao-esp32-c3': ['D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8', 'D9', 'D10', '3V3', 'GND', '5V'], + 'aitewinrobot-esp32c3-supermini': ['5', '6', '7', '8', '9', '10', 'RX', 'TX', '5V', 'GND', '3V3', '4', '3', '2', '1', '0'], +}; + +/** + * The DevKitC V4 breaks out the SPI-flash block (GPIO6-11). Those pads are + * unresolved today and stay that way here deliberately: they are not usable + * as GPIOs, and mapping them would invite wiring to the flash bus. Listed + * rather than skipped so the gap is visible. + */ +const UNRESOLVED_BY_DESIGN: Record = { + 'esp32-devkit-c-v4': ['D0', 'D1', 'D3', 'CMD', 'CLK'], +}; + +const SUPPLY_PAD = /^(GND|VSS|3V|3V3|5V|VCC|VDD|VIN|VBUS|VBAT|BAT|EN|RST)([._]?\d+)?$/i; + +describe('boardPinToNumber — ESP32 family pads', () => { + for (const [kind, names] of Object.entries(ESP32_FAMILY_PADS)) { + const allowed = UNRESOLVED_BY_DESIGN[kind] ?? []; + + it(`${kind}: every pad resolves`, () => { + const unresolved = names + .filter((n) => !allowed.includes(n)) + .filter((n) => boardPinToNumber(kind, n) === null); + expect(unresolved).toEqual([]); + }); + + it(`${kind}: no supply pad resolves to a GPIO`, () => { + const driving = names + .filter((n) => SUPPLY_PAD.test(n)) + .filter((n) => (boardPinToNumber(kind, n) ?? -1) >= 0); + expect(driving).toEqual([]); + }); + } +}); + +describe('boardPinToNumber — the pads that were dead', () => { + it('DevKit V1 UART pads reach GPIO1 and GPIO3', () => { + // RX2/TX2 have numeric twins at the same coordinate ('16'/'17', added by + // be24243e); RX0/TX0 have none, so they were the only route to 3 and 1. + expect(boardPinToNumber('esp32', 'RX0')).toBe(3); + expect(boardPinToNumber('esp32', 'TX0')).toBe(1); + expect(boardPinToNumber('esp32', 'RX2')).toBe(16); + expect(boardPinToNumber('esp32', 'TX2')).toBe(17); + }); + + it('the S3 reaches its GPIOs above 39, the classic ESP32 does not', () => { + for (const n of ['40', '41', '42', '45', '46', '47', '48']) + expect(boardPinToNumber('esp32-s3', n)).toBe(Number(n)); + expect(boardPinToNumber('esp32', '40')).toBeNull(); + }); + + it('the Nano ESP32 reaches GPIO44/43 through the pads it actually draws', () => { + expect(boardPinToNumber('arduino-nano-esp32', 'RX0')).toBe(44); + expect(boardPinToNumber('arduino-nano-esp32', 'TX1')).toBe(43); + }); + + it('supply pads are -1, not the digit their name starts with', () => { + expect(boardPinToNumber('wemos-lolin32-lite', '3V')).toBe(-1); + expect(boardPinToNumber('xiao-esp32-s3', '3V3')).toBe(-1); + expect(boardPinToNumber('xiao-esp32-s3', '5V')).toBe(-1); + expect(boardPinToNumber('xiao-esp32-c3', '3V3')).toBe(-1); + expect(boardPinToNumber('aitewinrobot-esp32c3-supermini', '5V')).toBe(-1); + expect(boardPinToNumber('arduino-nano-esp32', '3V3')).toBe(-1); + }); + + it('reset pads are -1, not null', () => { + for (const kind of ['esp32-s3', 'esp32-c3', 'arduino-nano-esp32']) + expect(boardPinToNumber(kind, 'RST')).toBe(-1); + }); +}); diff --git a/frontend/src/simulation/spice/boardPinGroups.ts b/frontend/src/simulation/spice/boardPinGroups.ts index 08113207..8433e473 100644 --- a/frontend/src/simulation/spice/boardPinGroups.ts +++ b/frontend/src/simulation/spice/boardPinGroups.ts @@ -117,7 +117,9 @@ export const BOARD_PIN_GROUPS: Record = { 'wemos-lolin32-lite': { vcc: 3.3, gnd: ['GND'], - vcc_pins: ['3V3'], + // The element silkscreens the rail "3V" (Esp32Element.ts PINS_WEMOS_LOLIN32); + // "3V3" is kept for saved projects that named it that way. + vcc_pins: ['3V', '3V3'], aux: { volts: 5, pins: ['5V'] }, }, 'esp32-s3': { diff --git a/frontend/src/utils/boardPinMapping.ts b/frontend/src/utils/boardPinMapping.ts index b3d836da..15b1a128 100644 --- a/frontend/src/utils/boardPinMapping.ts +++ b/frontend/src/utils/boardPinMapping.ts @@ -129,14 +129,36 @@ export const PI3_BCM_TO_PHYSICAL: Record = Object.fromEntries( .map(([physical, bcm]) => [bcm, Number(physical)]), ); +/** + * Pad names that are a supply, ground or reset — never a GPIO. + * + * Several boards silkscreen their 3.3 V rail as "3V" and their reset as "RST", + * and the numeric fallback below is `parseInt(pinName, 10)`: parseInt('3V', 10) + * is 3, so the Wemos Lolin32 Lite's supply pad resolved to GPIO3 and the XIAO's + * 3V3/5V pads to GPIO3/GPIO5. A wire to the supply drove a real pin. Matching + * these first also turns them into -1 rather than null, which is what tells + * WirePin (and the ground check in SimulatorCanvas) to skip them silently. + */ +const POWER_PAD_RE = + /^(gnd|vss|vee|3v3|3v|3\.3v|5v|vcc|vdd|vin|vbus|vbat|bat|en|rst|reset|chip_pu)([._]?\d+)?$/i; + /** * ESP32 DevKit-C GPIO pin names → GPIO numbers. * Pin names are GPIO numbers directly (GPIO0–GPIO39). - * Special aliases: TX=1, RX=3. + * Special aliases: the UART pads (TX/RX = UART0, RX0/TX0 the same pads under + * their DevKit V1 silkscreen names, RX2/TX2 = UART2 on GPIO16/17). */ const ESP32_PIN_MAP: Record = { TX: 1, RX: 3, + // UART pads on the DevKit V1 silkscreen. Without these the four pads + // resolved to null: RX2/TX2 have numeric twins at the same coordinate + // ('16'/'17'), but RX0/TX0 do not, so they were the only route to GPIO3 + // and GPIO1 and a wire to them did nothing at all. + RX0: 3, + TX0: 1, + RX2: 16, + TX2: 17, GPIO0: 0, GPIO1: 1, GPIO2: 2, @@ -381,17 +403,22 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul // ESP32 / ESP32-S3 / ESP32-C3 — GPIO numbers used directly if (boardId === 'esp32' || boardId.startsWith('esp32')) { - // Power / GND pins (GND, GND.1, 3V3, 3V3.1, 5V, 5V.1, etc.) - if (pinName.startsWith('GND') || pinName.startsWith('3V3') || pinName.startsWith('5V')) - return -1; - // Try bare number first ("13" → 13) + // Power / GND / reset pads (GND, GND.1, 3V3, 5V, EN, RST, ...) + if (POWER_PAD_RE.test(pinName)) return -1; + // Try bare number first ("13" → 13). The ceiling is per family: the + // classic ESP32 tops out at GPIO39, the S3 at GPIO48. A shared 39 + // rejected the S3 DevKitC's 40/41/42/45/46/47/48 pads, so seven pads + // on that board resolved to null and a wire to them did nothing. + const maxGpio = boardId.startsWith('esp32-s3') ? 48 : 39; const num = parseInt(pinName, 10); - if (!isNaN(num) && num >= 0 && num <= 39) return num; + if (!isNaN(num) && num >= 0 && num <= maxGpio) return num; return ESP32_PIN_MAP[pinName] ?? null; } // ESP32 variants not starting with 'esp32' if (boardId === 'wemos-lolin32-lite') { + // Supply pads first — this board silkscreens its rail "3V", not "3V3". + if (POWER_PAD_RE.test(pinName)) return -1; // Pins named "GPIO34", "GPIO32" etc → strip prefix if (pinName.startsWith('GPIO')) return parseInt(pinName.substring(4), 10); const num = parseInt(pinName, 10); @@ -400,6 +427,7 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul } if (boardId === 'xiao-esp32-s3' || boardId === 'arduino-nano-esp32') { + if (POWER_PAD_RE.test(pinName)) return -1; // D0-D13, A0-A7 → ESP32-S3 GPIO numbers const XIAO_S3_MAP: Record = { D0: 1, @@ -437,6 +465,12 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul A5: 12, A6: 13, A7: 14, + // The D0/D1 positions are silkscreened RX0/TX1, so the D-names above + // were unreachable: GPIO44 and GPIO43 had no pad that resolved to them. + RX0: 44, + TX1: 43, + B0: 46, + B1: 0, }; const map = boardId === 'arduino-nano-esp32' ? NANO_ESP32_MAP : XIAO_S3_MAP; if (pinName in map) return map[pinName]; @@ -446,6 +480,7 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul } if (boardId === 'xiao-esp32-c3') { + if (POWER_PAD_RE.test(pinName)) return -1; const XIAO_C3_MAP: Record = { D0: 2, D1: 3, @@ -466,6 +501,7 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul } if (boardId === 'aitewinrobot-esp32c3-supermini') { + if (POWER_PAD_RE.test(pinName)) return -1; const num = parseInt(pinName, 10); if (!isNaN(num)) return num; return ESP32_PIN_MAP[pinName] ?? null;