ESP32 · 01-foundations
Hardware
Note: This document was generated by an AI agent.
Board
ESP32-D0WD-V3 (the classic dual-core ESP32), on a dev board with a WCH CH9102 USB-to-UART bridge.
In Arduino IDE select Tools → Board → esp32 → ESP32 Dev Module. Selecting an
S2/S3/C3 variant produces This chip is ESP32, not ESP32-S2 at upload time.
The board shows up on macOS as /dev/cu.usbserial-*. Arduino labels the port
"Unknown" because the CH9102's USB ID is not in its lookup table — that is
cosmetic; select the port anyway.
GPIO is not uniform, and that explains the pinout
The single most useful thing to know about this chip before wiring anything: the ESP32's 34 GPIOs are not interchangeable. They fall into four classes, and only one of them behaves the way a pin diagram implies.
| Class | Pins | What is different about them |
|---|---|---|
| General | most of the rest | Input, output, pull-ups, interrupts — what you expect |
| Input-only | 34, 35, 36, 39 | No output driver at all, and no internal pull-up or pull-down. An external resistor is mandatory if the line ever floats |
| Wired to flash | 6–11 | Connected to the SPI flash the firmware runs from. Using one does not misbehave, it stops the chip booting |
| Strapping | 0, 2, 5, 12, 15 | Read at reset to decide boot mode, flash voltage and log verbosity. They work afterwards, but what is attached at the moment of reset matters |
GPIO12 deserves its own sentence: it selects the flash voltage regulator at reset. Pulling it high on a board with 3.3 V flash can make it unbootable without external intervention. It is the one pin on this chip where a wiring mistake is not recoverable by reflashing.
The pin budget
| Count | |
|---|---|
| GPIOs the chip exposes (0–19, 21–23, 25–27, 32–39) | 34 |
| Lost to the SPI flash (6–11) | −6 |
| Lost to the USB serial console (1, 3) | −2 |
| Practically available | 26 |
| Claimed by this project | −10 |
| Free, of which five carry boot meaning | 16 |
One more rule that is not about pins at all
ADC2 stops working while Wi-Fi is on. The radio and the second ADC share hardware, and once the driver is up, an ADC2 read returns garbage or nothing. That covers GPIOs 0, 2, 4, 12–15 and 25–27.
This is why the default battery sense pin is GPIO 34 — an ADC1 pin, which keeps reading with the radio up. A battery divider on GPIO25 would work perfectly on the bench and read zero the moment the device joined a network.
Both input-only pins in the wiring are used for inputs, which costs nothing: GPIO35 receives microphone data and never needs to drive, and GPIO34 reads the battery divider. Spending a fully general pin on either would be waste.
Flashing
Set Tools → Partition Scheme → Minimal SPIFFS (1.9MB APP with OTA); the project's build script pins this for you. It gives 1,966,080 bytes of application space against the default 1,310,720, and keeps the second app slot that network flashing needs. Huge APP offers 3MB but has no second slot and cannot flash over WiFi — this project has no use for the extra space and every use for OTA.
Two routes exist, and you want the first: flashing over WiFi takes a few seconds; flashing over USB takes about 72 seconds.
USB is slow because 115200 baud is the only rate this adapter holds. Keep it working regardless: USB is the recovery path for when WiFi is down or a bad image is running, and it is the only way to flash a board that cannot join the network.
Network flashing needs the board already running firmware with over-the-air
updates enabled and associated to WiFi. It answers to a configurable mDNS
hostname by default (robotface.local); pass an address instead if mDNS is
unreliable on your network, by targeting the device's IP address directly.
A failed transfer leaves the previous firmware running — that is the point of the second slot. The OLED shows the reason.
Wiring
| Device | Pin | ESP32 |
|---|---|---|
| SSD1306 OLED | SDA | GPIO 21 |
| SCL | GPIO 22 | |
| VCC | 3.3V | |
| GND | GND | |
| TTP223 touch | I/O (SIG) | GPIO 4 |
| VCC | 3.3V | |
| GND | GND | |
| INMP441 mic | BCLK | GPIO 32 |
| LRC (WS) | GPIO 33 | |
| DOUT | GPIO 35 | |
| L/R | GND (selects the left slot) | |
| VDD | 3.3V | |
| GND | GND | |
| MAX98357A amp | BCLK | GPIO 26 |
| LRC (WS) | GPIO 25 | |
| DIN | GPIO 27 | |
| VIN | 5V | |
| GND | GND |
Pins are set in the project's central configuration and in the display driver's setup for the panel's I2C lines.
The OLED
A 0.96" SSD1306, 128x64, I2C at address 0x3C. Some modules answer at 0x3D instead; an I2C bus scan (any of the standard Arduino I2C-scanner examples) will tell you which, and a setting in the display driver controls it.
I2C runs at 400 kHz (fast mode), set explicitly during display initialization.
That is not a preference, and the arithmetic shows why. The framebuffer is
128 × 64 ÷ 8 = 1,024 bytes. I2C spends nine bit periods per byte — eight data
bits plus the receiver's ACK — and the Adafruit driver sends the buffer in chunks,
each prefixed by the device address and a 0x40 data-control byte:
per chunk 9 bits address + 9 bits control + 9 bits × chunk size
32 chunks × 32 bytes, plus an 8-byte command prologue
= 9,864 bits for one full frame
at 400 kHz 9,864 / 400,000 = 24.7 ms
at 100 kHz 9,864 / 100,000 = 98.6 ms
Against a 33 ms frame budget, fast mode leaves about 8 ms for everything else the loop does. The 100 kHz default leaves −66 ms: it cannot reach 30 fps, and in fact caps the panel at about 10 fps no matter what the CPU does.
This one number is the origin of several decisions elsewhere — the 10 fps information panels, the doubled playback interval for authored clips, and audio living on the other core. The overview collects those consequences.
The TTP223 touch module
A digital capacitive module: it drives its output pin high while touched.
This is why the firmware uses digitalRead and not the ESP32's built-in
touchRead — there is no baseline to calibrate against.
Check the solder jumpers on the back before wiring it. The two pads, usually marked A and B, change the behaviour:
| Jumpers | Behaviour | Works here? |
|---|---|---|
| Both open (factory default) | Active high, momentary — output is high only while touched | Yes |
| A bridged | Output inverted (active low) | Yes — set the active-high configuration option to 0 |
| B bridged | Toggle mode — output latches on each touch | No |
Toggle mode breaks the design: the output stops reflecting how long your finger is down, so press duration becomes meaningless and a long press can never fire. If taps work but long presses never do, this is the first thing to check.
The firmware logs every gesture over serial at 115200 ([touch] tap,
[touch] long press), which makes this quick to diagnose.
Tuning, in the project's central configuration:
- The long-press threshold (600 ms) — raise it if the gesture fires while you are only tapping, lower it if holding feels sluggish
- The touch debounce window (30 ms) — minimum press duration filter; input is sampled about every 33ms, so very short taps can be missed
- The active-high setting (1) — set it to 0 if your module idles high
The voice audio hardware (INMP441 + MAX98357A)
Gated by a dedicated configuration flag, independent of the general voice-enable setting — the Voice tab and its fallback text-only agent flow work with this left at its default off, no microphone or speaker required. Turn it on only once both are wired.
The INMP441 is a 24-bit I2S MEMS microphone on I2S0; its L/R pin tied to GND selects the left slot, which matches the configured input channel. The audio driver applies integer gain to the 24-bit sample before saturating it to 16-bit PCM at 16kHz, the input format used by the local transcription path.
The mic actually fitted is a board marked AX3C1192A1 — a reseller SKU, not a chip part number the INMP441 datasheet uses, but the board is pin- and protocol-compatible: the same six pins (VDD, GND, WS, SCK, SD, L/R), the same 24-bit I2S Philips format. Wire and configure it exactly as above.
This part is much quieter than it looks like it should be. Measured on real hardware, ordinary room ambience reaches only about -50dBFS peak at unity gain, and speech sits barely above that — quiet enough that a recording played back sounds like silence with a noise floor, which reads as "the microphone is broken" when it is working correctly. The microphone gain setting (default 8, about +18dB) exists for this. It is applied to the full 24-bit sample before narrowing to 16 bits, so it recovers real resolution rather than amplifying what was already discarded. Raise it if speech is still too quiet; drop it if loud sounds clip.
Never clean this board with isopropyl alcohol, and keep flux away from the mic port. Unlike every other part here, a MEMS microphone has an open acoustic port straight onto a micron-thin diaphragm; solvent wicks in by capillary action and can silence the mic permanently. One unit was lost this way during bring-up — it read as a sudden ~33dB drop to flatline, with every wire still passing a continuity test. Mask the port with Kapton tape if the board must be reworked, and use no-clean flux.
The MAX98357A is a class-D amp on I2S1 driving an 8-ohm speaker. The speaker's 1W rating is below what the amp can deliver at 5V, so playback volume is a software limit (tracked default 70 percent) rather than relying on the module's GAIN strap alone.
Both peripherals get their own DMA buffers and their own FreeRTOS task, both pinned to core 0 — see Runtime and character interaction for how that keeps the 30fps render loop unaffected by blocking I2S calls.
Before a cloud provider is wired up, a built-in test tone plays a bounded sine tone through the same playback path, which is enough to confirm the speaker and its wiring independently of any network agent.
Battery (not yet fitted)
The battery panel reports absent hardware rather than inventing a reading. To fit a cell, feed it through a divider into an ADC pin and enable the battery reading in configuration.
Use an ADC1 pin — ADC2 stops working while WiFi is on. GPIO 34, 35, 36 and 39 are input-only candidates, but GPIO 35 is already assigned to the microphone in this project's wiring; the battery default is GPIO 34. Two equal resistors (e.g. 100k/100k) give a divider ratio of 2.0, which keeps a 4.2V cell inside the ADC's 3.3V range.
The project's central configuration holds the battery pin, divider ratio, and
voltage range settings. Readings use analogReadMilliVolts(), which applies
the chip's eFuse calibration and is considerably more accurate than raw
analogRead().
A note on the internal temperature sensor
The System panel shows a die temperature labelled "uncal". On the ESP32 classic
temperatureRead() routes to an undocumented ROM function that returns an
uncalibrated die reading — typically around 53°C regardless of the room.
It is reported as a curiosity only. It is not ambient temperature and never drives the face; the data-driven expressions key off the weather API's real outdoor temperature instead.
When the hardware misbehaves
Wiring faults on this board present in a small number of recognisable ways. Every row here is something that actually happened during bring-up.
| Symptom | Cause | Where it is covered |
|---|---|---|
SSD1306 allocation failed at boot, then nothing | Wrong I2C address, or SDA/SCL swapped | Scan the bus; set the display's I2C address |
| Panel blank but the device answers HTTP | Power to the module, not the bus | Check 3.3 V and GND before the data lines |
| Animation stutters, audio is fine | Frame budget exceeded on core 1 | The runtime |
| Taps work, long presses never fire | TTP223 jumper B bridged — toggle mode | The touch section above |
| Every gesture reads inverted | TTP223 jumper A bridged | Set the active-high configuration option to 0 |
| The microphone level reading is a permanent exact zero | The configured input channel disagrees with the L/R strap | I2S runtime |
| Microphone audible but far too quiet | The microphone gain setting; the part is genuinely this insensitive | The microphone section above |
| Sudden ~33 dB drop to a flat noise floor, wiring still passes continuity | The acoustic port was contaminated — the mic is dead | The warning above |
| Battery reads plausibly on USB, zero once Wi-Fi joins | The divider is on an ADC2 pin | The ADC2 rule above |
| Board will not boot after rewiring | Something is attached to GPIO6–11, or GPIO12 is pulled high | The GPIO classes above |
This chip is ESP32, not ESP32-S2 at upload | An S2/S3/C3 board variant is selected | The board section above |
esptool connects, then Unable to verify flash chip connection | The adapter will not hold 921600 baud | Upload at 115200 baud instead |
Wiring invariants
| Invariant | Consequence of breaking it |
|---|---|
| Nothing is attached to GPIO6–11 | The chip does not boot; it cannot read its own firmware |
| GPIO12 is left floating or low at reset | Selecting the wrong flash voltage can make the board unbootable without external recovery |
| The battery divider stays on an ADC1 pin | Readings die silently the moment the radio comes up |
| The mic's L/R strap matches the configured input channel | Capture is an exact, permanent zero, with no error reported anywhere |
| I2C stays at 400 kHz | The panel caps at about 10 fps and the character stops looking alive |
| The amplifier is fed from 5 V, the mic and panel from 3.3 V | Under-volting the amp clips early; over-volting the mic destroys it |
| USB upload keeps working even when OTA is the normal path | A board that cannot reach the network has no other way in |
Pin reference
Everything the firmware claims.
| Signal | GPIO | Class |
|---|---|---|
| OLED SDA | 21 | general |
| OLED SCL | 22 | general |
| TTP223 I/O | 4 | general, ADC2 |
| Mic BCLK | 32 | general |
| Mic WS | 33 | general |
| Mic DOUT | 35 | input-only |
| Amp BCLK | 26 | general, ADC2 |
| Amp WS | 25 | general, ADC2 |
| Amp DIN | 27 | general, ADC2 |
| Battery sense | 34 | input-only, ADC1 |
Other relevant settings: the display's I2C address (0x3C), reset line (none — the module has no reset pin), long-press threshold (600 ms), touch debounce window (30 ms), active-high polarity (on), microphone gain (8), speaker volume (70 percent), battery divider ratio (2.0), battery reporting (off by default), and I2S audio (off by default).
Adding a peripheral
- Pick from what is left, using the pin budget above. Sixteen pins are free and five of those carry boot meaning.
- Match the class to the job. An input-only pin for anything that only receives; never a flash pin; a strapping pin only if nothing is attached to it at reset.
- If it is analogue, use ADC1 unless the device will never have Wi-Fi up.
- Check the bus budget, not just the pin. Another I2C device shares the 8 ms of slack the panel leaves; a second SPI device does not.
- Add the pin to the project's example configuration template, not only to your own local copy.
- Report absence honestly. A peripheral that is not fitted renders a placeholder saying so, as the battery panel does.