ivãstival

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.

Two panels. The left lists the ten pins this project claims: GPIO21 and 22 for the panel's I2C, GPIO4 for touch, GPIO32, 33 and 35 for the microphone's clock, word select and data, GPIO26, 25 and 27 for the amplifier, and GPIO34 for the battery divider — with GPIO34 and 35 marked input-only. The right lists what is unavailable or avoided: GPIO6 to 11 wired to the SPI flash, GPIO0, 2, 12 and 15 as strapping pins that change boot behaviour, GPIO1 and 3 as the serial console, and every ADC2 pin becoming unreadable while Wi-Fi is up.

ClassPinsWhat is different about them
Generalmost of the restInput, output, pull-ups, interrupts — what you expect
Input-only34, 35, 36, 39No output driver at all, and no internal pull-up or pull-down. An external resistor is mandatory if the line ever floats
Wired to flash6–11Connected to the SPI flash the firmware runs from. Using one does not misbehave, it stops the chip booting
Strapping0, 2, 5, 12, 15Read 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 available26
Claimed by this project−10
Free, of which five carry boot meaning16

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

DevicePinESP32
SSD1306 OLEDSDAGPIO 21
SCLGPIO 22
VCC3.3V
GNDGND
TTP223 touchI/O (SIG)GPIO 4
VCC3.3V
GNDGND
INMP441 micBCLKGPIO 32
LRC (WS)GPIO 33
DOUTGPIO 35
L/RGND (selects the left slot)
VDD3.3V
GNDGND
MAX98357A ampBCLKGPIO 26
LRC (WS)GPIO 25
DINGPIO 27
VIN5V
GNDGND

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:

JumpersBehaviourWorks here?
Both open (factory default)Active high, momentary — output is high only while touchedYes
A bridgedOutput inverted (active low)Yes — set the active-high configuration option to 0
B bridgedToggle mode — output latches on each touchNo

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.

SymptomCauseWhere it is covered
SSD1306 allocation failed at boot, then nothingWrong I2C address, or SDA/SCL swappedScan the bus; set the display's I2C address
Panel blank but the device answers HTTPPower to the module, not the busCheck 3.3 V and GND before the data lines
Animation stutters, audio is fineFrame budget exceeded on core 1The runtime
Taps work, long presses never fireTTP223 jumper B bridged — toggle modeThe touch section above
Every gesture reads invertedTTP223 jumper A bridgedSet the active-high configuration option to 0
The microphone level reading is a permanent exact zeroThe configured input channel disagrees with the L/R strapI2S runtime
Microphone audible but far too quietThe microphone gain setting; the part is genuinely this insensitiveThe microphone section above
Sudden ~33 dB drop to a flat noise floor, wiring still passes continuityThe acoustic port was contaminated — the mic is deadThe warning above
Battery reads plausibly on USB, zero once Wi-Fi joinsThe divider is on an ADC2 pinThe ADC2 rule above
Board will not boot after rewiringSomething is attached to GPIO6–11, or GPIO12 is pulled highThe GPIO classes above
This chip is ESP32, not ESP32-S2 at uploadAn S2/S3/C3 board variant is selectedThe board section above
esptool connects, then Unable to verify flash chip connectionThe adapter will not hold 921600 baudUpload at 115200 baud instead

Wiring invariants

InvariantConsequence of breaking it
Nothing is attached to GPIO6–11The chip does not boot; it cannot read its own firmware
GPIO12 is left floating or low at resetSelecting the wrong flash voltage can make the board unbootable without external recovery
The battery divider stays on an ADC1 pinReadings die silently the moment the radio comes up
The mic's L/R strap matches the configured input channelCapture is an exact, permanent zero, with no error reported anywhere
I2C stays at 400 kHzThe 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 VUnder-volting the amp clips early; over-volting the mic destroys it
USB upload keeps working even when OTA is the normal pathA board that cannot reach the network has no other way in

Pin reference

Everything the firmware claims.

SignalGPIOClass
OLED SDA21general
OLED SCL22general
TTP223 I/O4general, ADC2
Mic BCLK32general
Mic WS33general
Mic DOUT35input-only
Amp BCLK26general, ADC2
Amp WS25general, ADC2
Amp DIN27general, ADC2
Battery sense34input-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

  1. Pick from what is left, using the pin budget above. Sixteen pins are free and five of those carry boot meaning.
  2. 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.
  3. If it is analogue, use ADC1 unless the device will never have Wi-Fi up.
  4. 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.
  5. Add the pin to the project's example configuration template, not only to your own local copy.
  6. Report absence honestly. A peripheral that is not fitted renders a placeholder saying so, as the battery panel does.