The boards
Twelve units, five models, but only three distinct silicon families — and the family, not the board name, decides what a sketch can do. Two of the five models are the same chip as a third, which is why the shared core below is worth writing once.
Inventory 12 boards · 5 models · 3 families
| Board | Qty | Module | Family | Core | MHz | SRAM | Flash | USB | Radios |
|---|---|---|---|---|---|---|---|---|---|
| ESP32 DEVKIT V1 Type-C | 3 | ESP32-WROOM-32 | ESP32 | 2× Xtensa LX6 | 240 | 520 KB | 4 MB | bridge chip | Wi-Fi 4 · BT Classic · BLE 4.2 |
| MIN132 V1.0.0 WiFi | 3 | ESP-WROOM-32 | ESP32 | 2× Xtensa LX6 | 240 | 520 KB | 4 MB | bridge chip | Wi-Fi 4 · BT Classic · BLE 4.2 |
| Freenove ESP32 WROOM | 2 | ESP32-WROOM-32E | ESP32 | 2× Xtensa LX6 | 240 | 520 KB | 4 MB | bridge chip | Wi-Fi 4 · BT Classic · BLE 4.2 |
| ESP32-C3 Super Mini | 2 | ESP32-C3 | C3 | 1× RISC-V RV32IMC | 160 | 400 KB | 4 MB | native | Wi-Fi 4 · BLE 5.0 |
| Heltec HTIT-WB32LAF | 2 | ESP32-S3 + SX1262 | S3 | 2× Xtensa LX7 | 240 | 512 KB | 8 MB | native | Wi-Fi 4 · BLE 5.0 · LoRa |
Eight of twelve are one chip
The DevKit V1, the MIN132 and the Freenove boards all carry the same ESP32-WROOM-32 silicon. The
-32E on the Freenove is a later module revision — smaller package options, updated
eFuse defaults — not a different part from the programmer's side.
Practical effect: one binary, one board target, one pin map covers 8 of the 12 boards. The remaining four are the two C3 Super Minis and the two Heltecs, and those are where the divergences in §02 apply.
Reading the family off a board
Or ask the chip: esptool.py --port <port> chip_id prints the exact
variant and revision without flashing anything.
What every one of them shares
These hold across all 12 boards and are the reason a single sheet works:
loop() is a task; the scheduler is always runningPortability matrix
Everything below is a compile-time or runtime difference, not a pin difference. A sketch that uses anything in the first table will not survive a move between families. Check here first; the rest of this sheet is common ground.
Not portable the whole list
| Feature / API | ESP32 WROOM-32 | C3 Super Mini | S3 Heltec | What breaks, and the way round it |
|---|---|---|---|---|
| Bluetooth Classic BluetoothSerial.h | yes | no | no | C3 and S3 are BLE-only radios. #include <BluetoothSerial.h> fails to compile with Bluetooth is not enabled. Port to BLE (NimBLE UART service) or keep that job on a WROOM board. |
| dacWrite() true analog out | GPIO25/26 | none | none | The 8-bit DACs exist only on the original ESP32. Neither C3 nor S3 has one. Substitute LEDC PWM through an RC low-pass, or an external I²C DAC (MCP4725). |
| touchRead() capacitive touch | 10 ch | none | 14 ch | C3 has no touch peripheral at all — the function does not exist. Touch pin numbering also differs between ESP32 (T0–T9) and S3 (T1–T14), so even ESP32↔S3 needs the pin re-picked. |
| esp_sleep_enable_ ext0_wakeup() | yes | no | no | ext0 is an original-ESP32 peripheral. On C3/S3 use esp_deep_sleep_enable_gpio_wakeup(), or ext1 where available. Silent behaviour change if you only test on one family. |
| ADC2 while Wi-Fi is up | blocked | avoid | avoid | On classic ESP32 the Wi-Fi driver owns ADC2, so analogRead() on GPIO0/2/4/12–15/25–27 returns garbage or -1 with the radio on. Keep every analog input on ADC1. |
| Core pinning xTaskCreatePinnedToCore | 2 cores | 1 core | 2 cores | C3 is single-core. The call still compiles, but core 1 does not exist and the task lands on core 0 — any design that assumes true parallelism serialises. |
| Serial over USB | bridge IC | native CDC | native CDC | Different device node, different reset behaviour, and on C3/S3 the Arduino option USB CDC On Boot must be Enabled or Serial prints to a UART you are not watching. See §05. |
| LEDC PWM channels | 16 | 6 | 8 | A design driving 8 independent PWM outputs works on ESP32 and S3 and runs out of channels on C3. Channels also pair off onto shared timers, so distinct frequencies cost more than distinct duties. |
| Hardware UARTs | 3 | 2 | 3 | One is spoken for by the console. Serial2 does not exist on C3. |
| Flash-reserved GPIO | 6–11 | 11–17 | 26–32 | Entirely different holes in the pin space. A pin number hard-coded for one family may be wired to the SPI flash on another — that is a board that stops booting, not a pin that misreads. |
| PSRAM | none | none | none* | None of these twelve has PSRAM. ps_malloc() falls back to internal RAM. *Other S3 modules do have it; the Heltec V3 does not. |
| USB HID / MSC | no | limited | yes | Classic ESP32 has no USB peripheral, so it cannot be a keyboard or a mass-storage device. S3 has full USB-OTG; C3's is serial/JTAG-focused. |
The five-line portability guard
Rather than remembering the table, let the preprocessor refuse to build the wrong thing:
// arduino-esp32 defines exactly one of these #if defined(CONFIG_IDF_TARGET_ESP32) #include <BluetoothSerial.h> // classic BT: this family only #define HAS_DAC 1 #elif defined(CONFIG_IDF_TARGET_ESP32C3) #define HAS_DAC 0 #define SINGLE_CORE 1 #elif defined(CONFIG_IDF_TARGET_ESP32S3) #define HAS_DAC 0 #else #error "Unhandled ESP32 target" #endif
The #error is the useful half — it turns "wrong board selected" from a
runtime mystery into a build failure on the line that says so.
Portable by construction
Habits that keep a sketch family-agnostic without any #ifdef:
constexpr int PIN_x block at the top; porting becomes editing five linesWire.begin(SDA_PIN, SCL_PIN) — defaults differ per family and are actively hostile on the C3dacWrite exists on onexTaskCreatelets the scheduler place it; pin only when you have measured a reason toesp_sleep_enable_timer_wakeup() is identical on all three; GPIO wake is notPin maps
Because of the GPIO matrix, these tables are about which pins exist and which are spoken for — not about fixed peripheral assignments. The defaults shown are what the Arduino core picks if you do not say otherwise.
ESP32 DevKit V1 · 30-pin also Freenove · MIN132 (verify pin count)
Gold = has a condition attached (strapping, flash, or ADC2). Input only means no output driver and no internal pull-up or pull-down — GPIO34–39 need an external resistor for a button. Clone silkscreens reorder the power pins; the set of pins is what matters here, not the row order.
SD0–SD3 / CMD / CLK — and driving one
stops the board booting. If a MIN132 turns out to be 38-pin, that is the difference that matters, not the extra
width.ESP32-C3 Super Mini
Wire to SDA=8, SCL=9 — which on the Super Mini are the LED and the BOOT button.
Always call Wire.begin(6, 7) or similar with pins you chose.GPIO11–17 are the SPI flash and are not brought out. GPIO18/19 are the native USB D−/D+ and are also not on the header.
Heltec WiFi LoRa 32 V3 fixed on-board nets
Most of this board's pins are already wired to something. These are the assignments you need before any free GPIO matters:
| Net | GPIO | Note |
|---|---|---|
| SX1262 NSS | 8 | LoRa chip select |
| SX1262 SCK | 9 | dedicated SPI bus |
| SX1262 MOSI | 10 | |
| SX1262 MISO | 11 | |
| SX1262 RST | 12 | |
| SX1262 BUSY | 13 | poll before every command |
| SX1262 DIO1 | 14 | TX/RX done interrupt |
| OLED SDA | 17 | SSD1306 128×64, its own I²C bus |
| OLED SCL | 18 | |
| OLED RST | 21 | pulse low then high at start-up |
| Vext control | 36 | active LOW — powers the OLED rail |
| User LED | 35 | white |
| PRG button | 0 | also the boot strap |
| VBAT sense | 1 | gated by ADC_Ctrl on GPIO37 |
pinMode(36,OUTPUT); digitalWrite(36,LOW); then wait ~50 ms before
touching the bus.Strapping pins — read at reset
These are sampled in the microseconds after reset to decide boot mode. A circuit that holds one at the wrong level makes a board that will not start — and the symptom is silence, not an error.
Toolchain
All four toolchains support all three families. The only thing that changes between boards is the target identifier — one string in one place.
Board targets the one line that differs per board
| Board | Arduino FQBN | PlatformIO board | ESP-IDF target |
|---|---|---|---|
| DevKit V1 (30-pin) | esp32:esp32:esp32doit-devkit-v1 | esp32doit-devkit-v1 | esp32 |
| MIN132 / generic WROOM | esp32:esp32:esp32 | esp32dev | esp32 |
| Freenove ESP32 WROOM | esp32:esp32:esp32 | esp32dev | esp32 |
| ESP32-C3 Super Mini | esp32:esp32:esp32c3 | esp32-c3-devkitm-1 | esp32c3 |
| Heltec WiFi LoRa 32 V3 | esp32:esp32:heltec_wifi_lora_32_V3 | heltec_wifi_lora_32_V3 | esp32s3 |
Arduino CLI
# once arduino-cli config init arduino-cli config add board_manager.additional_urls \ https://espressif.github.io/arduino-esp32/package_esp32_index.json arduino-cli core update-index arduino-cli core install esp32:esp32 # per project arduino-cli board list # find the port arduino-cli compile -b esp32:esp32:esp32doit-devkit-v1 . arduino-cli upload -b esp32:esp32:esp32doit-devkit-v1 -p /dev/cu.usbserial-0001 . arduino-cli monitor -p /dev/cu.usbserial-0001 -c baudrate=115200
Build options that are menu items in the IDE become --build-property or
a :-suffixed FQBN, e.g.
esp32:esp32:esp32c3:CDCOnBoot=cdc.
PlatformIO platformio.ini
[env] platform = espressif32 framework = arduino monitor_speed = 115200 build_flags = -DCORE_DEBUG_LEVEL=3 [env:devkit] board = esp32doit-devkit-v1 [env:c3] board = esp32-c3-devkitm-1 build_flags = ${env.build_flags} -DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1 [env:heltec] board = heltec_wifi_lora_32_V3 lib_deps = jgromes/RadioLib
One [env] per board, one shared src/.
pio run -e c3 -t upload builds and flashes just that target — the cleanest way to keep a
sketch honest across all three families.
ESP-IDF
. $HOME/esp/esp-idf/export.sh
idf.py set-target esp32c3 # esp32 | esp32c3 | esp32s3
idf.py menuconfig
idf.py build
idf.py -p /dev/cu.usbmodem101 flash monitor
sdkconfig and rebuilds it — save any hand edits in sdkconfig.defaultsCtrl-]The Arduino core is a component on top of IDF — anything in this sheet's IDF column is callable from an Arduino sketch by including the right header.
MicroPython
# flash the interpreter esptool.py --chip esp32 -p PORT erase_flash esptool.py --chip esp32 -p PORT -b 460800 \ write_flash 0x1000 ESP32_GENERIC-20250415.bin # C3 and S3 load at 0x0, not 0x1000 esptool.py --chip esp32c3 -p PORT write_flash 0x0 ESP32_GENERIC_C3-*.bin
mpremote connect PORT repl · mpremote fs cp main.py :machinePin, ADC, PWM, I2C, SPI, deepsleepnetworkWLAN(network.STA_IF) — same radio, different wordsDifferent firmware binary per family, same language. Handy on the C3, where the native USB means the REPL appears the instant the board enumerates.
Core 2.x vs 3.x
arduino-esp32 3.x moved to ESP-IDF v5.x and changed APIs that older tutorials still use. If sample code does not compile, this is usually why:
| Core 2.x | Core 3.x |
|---|---|
| ledcSetup(ch,f,res); ledcAttachPin(pin,ch); ledcWrite(ch,duty); | ledcAttach(pin,f,res); ledcWrite(pin,duty); |
| ledcDetachPin(pin) | ledcDetach(pin) |
| adcAttachPin(pin) | removed — not needed |
| analogSetClockDiv() | removed |
| SPIFFS.h | LittleFS.h preferred |
Check with ESP_ARDUINO_VERSION_MAJOR. The LEDC change is the one that
bites: ledcWrite still exists in 3.x but its first argument means something different, so
old code compiles cleanly and drives the wrong pin.
Build & flash
The flashing protocol is identical across the three families. What differs is how the board is put into download mode and what the serial port is called — and those two are the source of most upload failures.
Serial port & reset behaviour the first real divergence
| Family | USB path | macOS device | Enters download mode | Port after reset |
|---|---|---|---|---|
| ESP32 | CP2102 or CH340 bridge | /dev/cu.usbserial-* /dev/cu.wchusbserial-* /dev/cu.SLAB_USBtoUART | auto, via DTR/RTS transistors | stays put |
| C3 | native USB Serial/JTAG | /dev/cu.usbmodem* | auto, or hold BOOT + tap RST | re-enumerates |
| S3 | native USB Serial/JTAG | /dev/cu.usbmodem* | auto, or hold BOOT + tap RST | re-enumerates |
Connecting........_____, do it by hand: hold BOOT, tap
EN/RST, release BOOT, then start the upload. A USB cable that is charge-only produces exactly the same
symptom, as does a hub that cannot supply the radio's current peaks.Arduino IDE settings that matter
Serial goes to the hardware UART pins and the USB console stays silent — the single most common "my board is dead" on these twoESP_LOGx verbosity; Info is a good default, Verbose floodsesptool
# identify before you write esptool.py --port PORT chip_id esptool.py --port PORT flash_id # size + manufacturer # wipe (also clears NVS and any filesystem) esptool.py --chip esp32 --port PORT erase_flash # bootloader offset differs by family # esp32 -> 0x1000 # esp32c3 -> 0x0 # esp32s3 -> 0x0 esptool.py --chip esp32 --port PORT -b 460800 write_flash \ 0x1000 bootloader.bin 0x8000 partitions.bin 0x10000 firmware.bin # pull an image off a board before overwriting it esptool.py --port PORT read_flash 0 0x400000 backup.bin
macOS drivers
cu.usbserial-* with no installcu.wchusbserial*cu.*, not tty.*tty.* blocks waiting for carrier detect and will simply hangls /dev/cu.* # before and after plugging in
Boot messages, decoded
esp_restart() or the reset buttonGPIO & analog
Digital I/O is identical everywhere. Analog is where the three families quietly diverge — and where the ESP32's reputation for "inaccurate" readings comes from.
Digital portable across all 12
INPUT, OUTPUT, INPUT_PULLUP, INPUT_PULLDOWN, OUTPUT_OPEN_DRAINRISING FALLING CHANGE ONLOW ONHIGH — every GPIO can interruptvoid IRAM_ATTR isr(){} — mandatory if flash may be busyADC — the honest version
ADC_0db ~0–0.95 V · ADC_2_5db ~0–1.32 V · ADC_6db ~0–1.75 V · ADC_11db ~0–3.1 V defaultanalogReadMilliVolts()
corrects most of it; for real accuracy use an external ADC (ADS1115) or a ratiometric measurement.PWM (LEDC)
The ESP32 has no analogWrite hardware; the LED Control peripheral fills in, and it is
better — arbitrary frequency and up to 20-bit resolution.
// core 3.x ledcAttach(pin, 5000, 12); // 5 kHz, 12-bit ledcWrite(pin, 2048); // 50 % duty // servo: 50 Hz, 16-bit -> 1-2 ms is 3277-6554 ledcAttach(servoPin, 50, 16); ledcWrite(servoPin, 4915); // centre ledcWriteTone(pin, 440); // square wave, A4
freq × 2^bits ≤ 80 MHz. Ask for 20-bit at 5 kHz and the call fails silently, leaving a
dead output. Channel budget: 16 6
8.DAC and touch family-restricted
Replacing a DAC portably: LEDC at a few hundred kHz into a 1 kΩ / 1 µF RC gives a clean slow analog voltage. For audio, an external I²S DAC is the right answer on every family including the ESP32.
Buses
I²C, SPI, UART and I²S behave the same on all three families. Only the default pin choices differ — so pass pins explicitly and the code stops caring which board it is on.
I²C
#include <Wire.h> Wire.begin(SDA_PIN, SCL_PIN); // always name them Wire.setClock(400000); // 100k std, 400k fast Wire.beginTransmission(0x27); Wire.write(0x00); uint8_t err = Wire.endTransmission(); // 0 = ACKed
| Family | Default SDA | Default SCL | Buses |
|---|---|---|---|
| ESP32 | 21 | 22 | 2 |
| C3 | 8 | 9 | 1 |
| S3 | 8 | 9 | 2 |
Pull-ups: 4.7 kΩ to 3.3 V. Most breakout modules fit their own; three modules on one bus means three sets of pull-ups in parallel, which is often too strong.
// scanner — the first thing to run on any new bus for (uint8_t a = 1; a < 127; a++) { Wire.beginTransmission(a); if (Wire.endTransmission() == 0) Serial.printf("0x%02X\n", a); }
SPI
#include <SPI.h> SPI.begin(SCK, MISO, MOSI, SS); SPI.beginTransaction(SPISettings(10000000, MSBFIRST, SPI_MODE0)); digitalWrite(SS, LOW); SPI.transfer(0x9F); digitalWrite(SS, HIGH); SPI.endTransaction();
SPIClass hspi(HSPI); hspi.begin(14,12,13,15);UART
Serial.begin(115200); // console Serial1.begin(9600, SERIAL_8N1, RX, TX); // any pins, thanks to the GPIO matrix Serial2.begin(115200, SERIAL_8N1, 16, 17);
SerialUART0 — the console. On classic ESP32 it is GPIO1/3 and the bridge chip; on C3/S3 it is USB CDCSerial1free on every familySerial2ESP32S3 only — the C3 has no third UARTbegin(); the 256-byte default drops bytes at high ratesI²S and the other peripherals worth knowing
RMT is worth singling out: addressable LED strips driven from digitalWrite
loops are the classic reason a sketch flickers when Wi-Fi connects. Use a library that sits on RMT
(FastLED, Adafruit_NeoPixel on ESP32) and the problem disappears.
Wi-Fi
Identical on all twelve boards — same library, same calls, same behaviour. All of them are 2.4 GHz only, which is the one hardware fact that ever matters here.
Station mode
#include <WiFi.h>
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.setSleep(false); // latency over battery
WiFi.begin(ssid, pass);
while (WiFi.status() != WL_CONNECTED) {
delay(250); Serial.print('.');
}
Serial.println(WiFi.localIP());
}
WL_CONNECTED · WL_NO_SSID_AVAIL · WL_CONNECT_FAILED · WL_DISCONNECTEDWiFi.persistent(false) to stop rewriting NVS on every connectWIFI_POWER_11dBm) if a weak supply browns out on transmitstatus() in loop()Access point & provisioning
WiFi.mode(WIFI_AP); WiFi.softAP("esp32-setup", "12345678"); Serial.println(WiFi.softAPIP()); // 192.168.4.1 // both at once, for a config portal WiFi.mode(WIFI_AP_STA);
softAP() quietly creates an open networkWIFI_AP_STAone radio — the AP is forced onto the station's channelPreferences survives
reflashing (unless "Erase All Flash" is on) and keeps secrets out of anything you might share.HTTP, mDNS, time
#include <HTTPClient.h> HTTPClient http; http.begin("http://example.com/api"); int code = http.GET(); if (code == 200) Serial.println(http.getString()); http.end(); #include <ESPmDNS.h> MDNS.begin("sensor1"); // -> sensor1.local MDNS.addService("http", "tcp", 80); configTime(0, 0, "pool.ntp.org"); setenv("TZ", "EST5EDT,M3.2.0,M11.1.0", 1); tzset();
server.on("/", handler) — synchronous, called from loop()setInsecure() skips validation; setCACert() does it properlyMQTT_MAX_PACKET_SIZEOTA updates
#include <ArduinoOTA.h> ArduinoOTA.setHostname("sensor1"); ArduinoOTA.setPassword("…"); ArduinoOTA.begin(); // then in loop(): ArduinoOTA.handle();
.bin from a URL instead of pushing over the networkesp_ota_mark_app_valid_cancel_rollback() once the new image proves itselfBluetooth
The sharpest divide on this sheet. Only the eight WROOM-32 boards can do Bluetooth Classic. All twelve can do BLE — so BLE is the portable choice.
Classic ESP32 family only
#include <BluetoothSerial.h> // compile error on C3/S3 BluetoothSerial SerialBT; void setup() { SerialBT.begin("ESP32-bench"); } void loop() { if (SerialBT.available()) Serial.write(SerialBT.read()); }
Bluetooth is not enabled! Please run `make menuconfig`… — a clear message pointing at a
hardware fact that cannot be configured away.ESP32-A2DP. Large — expect to need the Huge APP partitionBLE all twelve boards
#include <NimBLEDevice.h> NimBLEDevice::init("esp32-node"); NimBLEServer *s = NimBLEDevice::createServer(); NimBLEService *sv = s->createService("180F"); // battery NimBLECharacteristic *c = sv->createCharacteristic( "2A19", NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); sv->start(); s->getAdvertising()->start(); c->setValue(pct); c->notify();
ESP-NOW the portable third option
Espressif's own connectionless protocol: board-to-board over the Wi-Fi radio, no access point involved, and it works on all three families identically.
#include <esp_now.h>
esp_now_init();
esp_now_peer_info_t p = {};
memcpy(p.peer_addr, mac, 6);
esp_now_add_peer(&p);
esp_now_send(mac, (uint8_t*)&payload, sizeof(payload));
For a sensor at the end of the garden reporting to a node indoors, ESP-NOW between two DevKits is simpler than either MQTT or LoRa — and unlike LoRa it needs no extra hardware.
Storage & partitions
Flash is carved up by a partition table chosen at build time. Getting this wrong is the cause of both "my settings vanished" and "OTA does nothing".
Preferences (NVS) the right place for settings
#include <Preferences.h> Preferences prefs; prefs.begin("net", false); // namespace, read-write prefs.putString("ssid", ssid); prefs.putUInt("boots", prefs.getUInt("boots", 0) + 1); String s = prefs.getString("ssid", ""); // default if unset prefs.end();
loop(); write on changenvs_flash_erase() wipes the lotLittleFS
#include <LittleFS.h> LittleFS.begin(true); // true = format if unmountable File f = LittleFS.open("/log.txt", FILE_APPEND); f.printf("%lu,%.2f\n", millis(), temp); f.close(); File d = LittleFS.open("/"); while (File e = d.openNextFile()) Serial.println(e.name());
/required on every pathLittleFS.totalBytes() / usedBytes()pio run -t uploadfs, or the IDE's filesystem upload pluginPartition schemes on 4 MB
| Scheme | App | OTA | FS | Use when |
|---|---|---|---|---|
| Default 4 MB | 1.2 MB ×2 | yes | 1.5 MB | the sane default |
| Minimal SPIFFS | 1.9 MB ×2 | yes | 190 KB | big sketch, still want OTA |
| Huge APP | 3 MB ×1 | no | 1 MB | BLE + Wi-Fi + audio, no OTA |
| No OTA (2 MB APP) | 2 MB ×1 | no | 2 MB | data logger |
RTC memory & other stores
Preferences is the honest APIesp_partition_find() for a raw data area outside any filesystemPower & sleep
The sleep modes are the same across all three families; the wake sources are not. And on a dev board, the board usually costs more current than the chip saves.
Sleep modes
| Mode | Chip draw | RAM | Resumes |
|---|---|---|---|
| Active, radio on | 80–260 mA | — | — |
| Modem sleep | ~20 mA | kept | instant |
| Light sleep | ~0.8 mA | kept | next line of code |
| Deep sleep | ~10 µA | lost | setup(), from the top |
| Hibernation | ~5 µA | lost | setup() |
#include <esp_sleep.h> RTC_DATA_ATTR int wakeCount = 0; esp_sleep_enable_timer_wakeup(60ULL * 1000000); // 60 s esp_deep_sleep_start(); // never returns
Wake sources — not portable
| Source | ESP32 | C3 | S3 |
|---|---|---|---|
| Timer | yes | yes | yes |
| ext0 (one pin) | yes | no | no |
| ext1 (pin mask) | yes | variant | yes |
| GPIO wake | light only | yes | yes |
| Touch | yes | no | yes |
| ULP | yes | no | yes |
// ESP32 only esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 1); // C3 / S3 esp_deep_sleep_enable_gpio_wakeup(BIT(4), ESP_GPIO_WAKEUP_GPIO_HIGH);
Only RTC-capable pins can wake the chip. On classic ESP32 that is GPIO0, 2, 4, 12–15, 25–27, 32–39 — never GPIO16–23. On the C3 it is GPIO0–5 only.
esp_sleep_get_wakeup_cause() returns
TIMER, EXT0, GPIO, UNDEFINED
(a genuine cold boot) — the cleanest way to branch between first-run setup and a routine wake.The dev-board tax
For a genuinely long-lived battery node the answer is a bare module on a board of your own, or the Heltec — which at least has a proper JST battery connector and charge circuit.
Powering the board
FreeRTOS
Always present, on every board. loop() is a task like any other — knowing that turns a
class of mysterious reboots into an obvious one.
Tasks
void sensorTask(void *arg) {
for (;;) {
readSensor();
vTaskDelay(pdMS_TO_TICKS(1000)); // yields
}
}
// portable: let the scheduler choose
xTaskCreate(sensorTask, "sensor", 4096, nullptr, 1, nullptr);
// dual-core only (ESP32, S3) — not the C3
xTaskCreatePinnedToCore(sensorTask, "sensor", 4096,
nullptr, 1, nullptr, 0);
String or TLS wants 8192loop() runs at 1loop() runs on core 1uxTaskGetStackHighWaterMark(nullptr) — the bytes of stack never used. Size from measurementThe watchdog
while in a task with no
vTaskDelay() starves the idle task on that core. The Task Watchdog fires, and the board
resets with Task watchdog got triggered — a message that names the task but not the line.vTaskDelay(), not delay()inside a task. (Arduino's delay() does yield on ESP32, but the intent is clearer.)taskYIELD()for a loop that must not sleep but must let others runesp_task_wdt_reset()feed it explicitly during a genuinely long operationBetween tasks
xQueueCreate(len, size) → xQueueSend / xQueueReceive. The default way to move dataxSemaphoreCreateMutex() around anything shared — an I²C bus touched from two tasks will corrupt…FromISR variants only, and keep the ISR to a flag or a queue sendportENTER_CRITICAL(&mux) for a few instructions — never around anything that blocksvolatileon every variable shared with an ISR, and it is still not atomic for anything wider than a wordOn the single-core C3 many races simply do not happen — which makes it a poor place to test concurrency you intend to run on a DevKit.
LoRa — Heltec only
The only section here that applies to two boards rather than twelve. Owning exactly two Heltecs is what makes them useful: a matched pair is a point-to-point link with nothing in between.
Confirm this before wiring the inventory records less than the board does
HTIT-WB32LAF. Everything in this section assumes the WiFi LoRa 32 V3
generation, which is ESP32-S3. Read the silkscreen and settle:
- Generation — a V3 board says so, and is S3. A V2 is a classic ESP32 with an SX1276, and then the ESP32 column of §02 applies instead, along with a completely different pin map and library.
- Frequency band — US 902–928 MHz or EU 863–870 MHz. The wrong band is not a configuration error; it is transmitting outside your licence-exempt allocation, and the matched antenna is cut for one of them.
Both are printed on the board or its shield. Until then, treat the pin table in §03 and the code below as V3 assumptions rather than facts.
RadioLib SX1262 on the V3
#include <RadioLib.h> // NSS, DIO1, RST, BUSY SX1262 radio = new Module(8, 14, 12, 13); SPI.begin(9, 11, 10); // SCK, MISO, MOSI int st = radio.begin( 905.2, // MHz — must match your band 125.0, // bandwidth kHz 9, // spreading factor 5..12 7, // coding rate 4/7 0x34, // sync word 14); // TX power dBm if (st != RADIOLIB_ERR_NONE) Serial.println(st); radio.transmit("hello"); String in; radio.receive(in);
Choosing the settings
For a sensor at the edge of the property reporting to the house, raw LoRa between the two Heltecs at SF9 is the right starting point — kilometres of range, no infrastructure, and a few hundred milliseconds of airtime per reading.
The onboard OLED
// 1. power the Vext rail (ACTIVE LOW) pinMode(36, OUTPUT); digitalWrite(36, LOW); delay(50); // 2. reset the panel pinMode(21, OUTPUT); digitalWrite(21, LOW); delay(20); digitalWrite(21, HIGH); // 3. its own I2C bus, separate from anything you add Wire1.begin(17, 18);
SSD1306, 128×64, address 0x3C. Skipping step 1 gives a display that never
responds and an I²C scan that finds nothing — indistinguishable from a dead panel.
Debug
The ESP32 crashes usefully — a panic prints a backtrace of return addresses that decodes back to file and line. Learning to read one is the highest-value ten minutes on this sheet.
Reading a panic
Guru Meditation Error: Core 1 panic'ed (LoadProhibited). Exception was unhandled. Core 1 register dump: PC : 0x400d1234 PS : 0x00060730 A0 : 0x800d0f1c ... Backtrace: 0x400d1234:0x3ffb1f10 0x400d15a8:0x3ffb1f30
0x00000000 is the usual causexTaskCreateIRAM_ATTRDecode it: the ESP Exception Decoder plugin in the Arduino IDE, or
pio device monitor -f esp32_exception_decoder, which decodes the backtrace live. By hand:
xtensa-esp32-elf-addr2line -pfiaC -e firmware.elf 0x400d1234.
Logging
#include <esp_log.h> static const char* TAG = "sensor"; ESP_LOGE(TAG, "error %d", err); // error ESP_LOGW(TAG, "warn"); // warning ESP_LOGI(TAG, "temp=%.2f", t); // info ESP_LOGD(TAG, "debug"); // debug esp_log_level_set("wifi", ESP_LOG_WARN); // quieten one tag
printf including %f — unlike the AVR coresString is usually the culpritESP32, ESP32-C3, ESP32-S3 — a one-line sanity check that you flashed what you thinksetup()Serial monitors & JTAG
screenscreen /dev/cu.usbserial-0001 115200 — quit with Ctrl-A then Ktiotio /dev/cu.usbmodem101 — reconnects by itself when the port disappears, which is exactly what a C3 needspio device monitorwith -f esp32_exception_decoder, the most useful of the threeAPI index
The Arduino-layer calls worth having to hand, with the family restriction where one applies. Use the filter box in the header — it searches this whole sheet and lands here.
System
getFreeSketchSpace()uint64_t — a durable device idTiming
int64_t — no practical rolloverif (now - last >= interval) — never compare absolute values with >GPIO & analog
INPUT OUTPUT INPUT_PULLUP INPUT_PULLDOWNHIGH / LOWADC_11db for 0–3.1 VWi-Fi & network
WIFI_STA WIFI_AP WIFI_AP_STA WIFI_OFFWL_CONNECTED etc.true also erases the stored credentialsSSID(i), RSSI(i), channel(i)begin()name.localgetLocalTime(&tm)Storage
false = read-writeString Int UInt Bool Float Bytestrue = format if it will not mountFILE_READ FILE_WRITE FILE_APPENDSleep & FreeRTOS
UNDEFINED means a genuine cold bootTake / GiveTraps
Failures that look like something other than what they are. Roughly in order of how much time they cost.
Silent and expensive
-1. Keep analog inputs on ADC1 (GPIO32–39)Serial goes to UART pins you are not watching. The board is running perfectly and looks deadledcWrite compiles under both corescore 2.x takes a channel, core 3.x takes a pin. Old code builds cleanly and drives the wrong outputArduinoOTA.begin() does not complainfreq × 2^bits > 80 MHz fails without a message and leaves a dead outputWire.begin()Looks like a code bug, is not
Connecting…hold BOOT, tap EN, release BOOT. Or the cable is charge-onlytio, which reconnectsvTaskDelay()String. Watch ESP.getMinFreeHeap(); switch to fixed buffersesptool.py erase_flash if you mean itBench-specific
0x27one I²C bus holds one device per address. Re-address two of them at the solder jumpers before running more than one