fix(boards): pads that resolved to nothing, and supply pads that drove a GPIO

boardPinToNumber had two failure shapes across the ESP32 family, both silent.

A pad the mapper cannot name returns null, and a null pin means the wire
connects to nothing: the part sits on the canvas, the sketch drives a dead
pin, and the only trace is one console.warn from WirePin.

  - DevKit V1 RX0/TX0. Unlike RX2/TX2, which have numeric twins at the same
    coordinate ('16'/'17', be24243e), these have none, so they were the only
    route to GPIO3 and GPIO1 and a wire to either did nothing.
  - S3 DevKitC 40/41/42/45/46/47/48, rejected by a GPIO ceiling of 39 shared
    with the classic ESP32. The ceiling is now per family.
  - Nano ESP32 RX0/TX1/B0/B1. Its map named them D0/D1, pads the element does
    not draw, leaving GPIO44 and GPIO43 unreachable.
  - Reset pads (RST) on the S3, C3 and Nano, now -1 like the classic board's
    EN rather than null.

The second shape is worse, because the pad does something wrong instead of
nothing: the numeric fallback is parseInt(pinName, 10), and parseInt('3V', 10)
is 3. The Wemos Lolin32 Lite's supply pad resolved to GPIO3, and the XIAO S3,
XIAO C3, Nano ESP32 and Aitewin C3 SuperMini resolved 3V3 to GPIO3 and 5V to
GPIO5 — a wire to the rail drove a real pin. Those branches had no power
guard at all; the shared POWER_PAD_RE replaces the ad-hoc startsWith checks.
Same reason the Wemos SPICE group now lists the '3V' pad it actually draws:
it declared '3V3', a name that board does not expose.

Every expected value is cross-checked against the `target` field of the
upstream wokwi-boards board.json, which is what surfaced this: the pad data
in PR #165 (naweiss) generates that field, and diffing it against our tables
turned up the whole class. Six of the ten ESP32-family boards now match
upstream exactly.

Left deliberately: the UART pad numbers (S3 TX/RX are GPIO43/44 and C3
TX/RX are GPIO21/20, not the classic 1/3) and the DevKitC V4 flash block
(D0-D3/CMD/CLK are GPIO6-11). Unlike the above, those pads resolve today, so
changing them moves an existing wire to a different pin.

The test walks every pad of every ESP32-family board and fails on either
shape. Pad lists are copied from Esp32Element.ts, as in
pin-inspector-layout.test.ts, because vitest runs in the node environment.

Co-authored-by: naweiss <naweiss@g.jct.ac.il>
This commit is contained in:
David Montero Crespo 2026-08-21 06:07:20 +02:00
parent 2b7c9b6fb6
commit 00e7dd8b11
3 changed files with 175 additions and 7 deletions

View File

@ -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<string, string[]> = {
'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<string, string[]> = {
'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);
});
});

View File

@ -117,7 +117,9 @@ export const BOARD_PIN_GROUPS: Record<AllBoardKinds, BoardPinGroup> = {
'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': {

View File

@ -129,14 +129,36 @@ export const PI3_BCM_TO_PHYSICAL: Record<number, number> = 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<string, number> = {
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<string, number> = {
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<string, number> = {
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;