A dual-boot setup for the Heltec WiFi LoRa 32 V4 (ESP32-S3, 16 MB flash) that lets you switch between Meshtastic and MeshCore from a boot-time selector menu on the OLED. Settings for both firmwares persist across switches.
Flash with esptool. To enter boot selector: Press reset and then hold program until boot selector loads. Press program to switch between the two systems and then wait 5 seconds for it to boot in.
This is version 3.0
- Fixed Bluetooth bonding issue when switching (maintained encryption), now meshcore advertises seperate mac address so you need only to setup each system once.
- Fixed seperate partition for each system data without corruption
Future (working) 3.5
- adding another partition for Meshcore Repeater
If you found this helpful, please consider buying me a coffee :) https://buymeacoffee.com/tkh1000
┌─────────────────────────────────┐
│ Dual-Boot Selector │
├───────────────┬─────────────────┤
│ MeshCore │ Mesht. │ ← PRG cycles selection
├───────────────┴─────────────────┤
│ PRG=cycle Boot in 5s │
│ v1.0 - Heltec V4 │
└─────────────────────────────────┘
| Flash region | Partition | Contents |
|---|---|---|
0x0000 |
bootloader | Custom bootloader (extended GPIO0 window + factory-reset) |
0x8000 |
partition table | partitions_dualboot.csv |
0x9000 |
nvs (64 KB) | Shared NVS — Arduino/IDF runtime state |
0x19000 |
nvs_sel (12 KB) | Isolated NVS — selector last-boot state |
0x1C000 |
otadata | OTA boot selection state |
0x20000 |
factory (2 MB) | Selector app — always runs on first/clean boot |
0x220000 |
ota_0 (4.4 MB) | MeshCore firmware |
0x680000 |
ota_1 (5.4 MB) | Meshtastic firmware |
0xBF0000 |
spiffs (2 MB) | Meshtastic LittleFS filesystem |
0xDF0000 |
mc_spiffs (2 MB) | MeshCore SPIFFS filesystem |
0xFF0000 |
coredump (64 KB) | Crash dump |
MeshCore and Meshtastic use different filesystem formats on ESP32:
- Meshtastic uses LittleFS (mounts the
spiffspartition) - MeshCore uses SPIFFS (mounts the
mc_spiffspartition)
Without isolation, each firmware would see the other's incompatible filesystem format on boot, format it, and wipe all settings. By giving each firmware its own dedicated partition label, they never touch each other's storage.
Boot flow:
- Power on → bootloader checks GPIO0 → runs selector (factory partition)
- Selector shows menu; PRG cycles choice; auto-boots after 5 s
- Selected firmware boots; selection saved to NVS for next auto-boot
- To return to selector from any firmware: tap RST, then press and hold PRG for 2 s
- Heltec WiFi LoRa 32 V4
- USB-C cable
- esptool:
pip install esptool
Both MeshCore (Companion Radio BLE) and Meshtastic (2.7.21) firmware are included.
Put device in bootloader mode: hold PRG → plug USB → release PRG
esptool.py --chip esp32s3 --port COM<N> --baud 921600 \
write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB \
0x0000 prebuilt/bootloader.bin \
0x8000 prebuilt/partition-table.bin \
0x1c000 prebuilt/ota_data_initial.bin \
0x20000 prebuilt/selector.bin \
0x220000 meshcore/firmware.bin \
0x680000 meshtastic/firmware.binOr use the included script:
python flash_all.py COM<N>The Heltec V4's built-in USB exposes a JTAG interface. If you can't get a serial COM port, use OpenOCD:
OPENOCD="<esp-idf-tools>/openocd-esp32/.../bin/openocd"
SCRIPTS="<esp-idf-tools>/openocd-esp32/.../share/openocd/scripts"
$OPENOCD -s $SCRIPTS -f board/esp32s3-builtin.cfg -c "
init; halt
flash write_image erase prebuilt/bootloader.bin 0x0
flash write_image erase prebuilt/partition-table.bin 0x8000
flash write_image erase prebuilt/ota_data_initial.bin 0x1c000
flash write_image erase prebuilt/selector.bin 0x20000
flash write_image erase meshcore/firmware.bin 0x220000
flash write_image erase meshtastic/firmware.bin 0x680000
reset run; exit
"| Action | Result |
|---|---|
| Press PRG | Cycle selection (MeshCore ↔ Meshtastic) |
| Wait 5 s | Auto-boot last-used firmware |
| Hold PRG at power-on | Force selector (disable auto-boot) |
- Tap RST (release it fully)
- Immediately press and hold PRG (within ~1.3 s of RST releasing)
- Hold PRG for 2 seconds
- Selector appears
Why this timing? GPIO0 (PRG) is an ESP32-S3 strapping pin. Holding it LOW while RST releases triggers ROM download mode instead of our bootloader. The PRG press must happen after RST releases. The custom bootloader extends the detection window to ~1.3 s.
cd selector
idf.py set-target esp32s3
idf.py build
# Outputs: build/bootloader/bootloader.bin
# build/heltec_v4_selector.bin
# build/partition_table/partition-table.bin
# build/ota_data_initial.bingit clone https://github.com/meshtastic/firmware meshtastic-firmware
cd meshtastic-firmware
pio run -e heltec-v4
# Output: .pio/build/heltec-v4/firmware-heltec-v4-*.bingit clone https://github.com/meshcore-dev/MeshCore meshcore-firmware
cd meshcore-firmware
# Apply partition table:
# In variants/heltec_v4/platformio.ini, add under [Heltec_lora32_v4]:
# board_build.partitions = <path>/partitions_dualboot.csv
# board_upload.maximum_size = 5767168
# In examples/companion_radio/main.cpp, change:
# SPIFFS.begin(true) → SPIFFS.begin(true, "/spiffs", 10, "mc_spiffs")
pio run -e heltec_v4_companion_radio_ble
# Output: .pio/build/heltec_v4_companion_radio_ble/firmware.binEach firmware advertises as a separate BLE device with its own unique MAC address:
| Firmware | BLE name | Address |
|---|---|---|
| Meshtastic | Meshtastic_XXYY |
Real hardware MAC |
| MeshCore | MeshCore_XXYY |
Derived random static MAC |
This means your phone stores two independent BLE bonds — one per firmware. Once paired to each, switching firmware never requires re-pairing or "Forget Device" — the phone automatically reconnects to the right bond.
MeshCore:
- Boot into MeshCore via the selector
- A PIN code is shown on the OLED display
- Open the MeshCore companion app → scan → connect to
MeshCore_XXYY→ enter the PIN - Bond is stored on your phone permanently
Meshtastic:
- Boot into Meshtastic via the selector
- Open the Meshtastic app → Bluetooth → connect to
Meshtastic_XXYY - Set a fixed PIN in Meshtastic app settings (optional but recommended)
After both are paired once, switching between firmwares is seamless — just use the selector and your phone reconnects automatically.
You do not need to reflash the selector, bootloader, or partition table when updating firmware versions.
Download the latest firmware-heltec-v4-X.X.X.bin from the Meshtastic releases page and flash only the Meshtastic slot:
esptool.py --chip esp32s3 --port COM<N> --baud 921600 \
write_flash 0x680000 firmware-heltec-v4-X.X.X.binThe MeshCore binary includes custom BLE patches for this dual-boot setup (random static address, MeshCore_ name prefix). You must rebuild from source:
cd meshcore-firmware
git pull
pio run -e heltec_v4_companion_radio_bleThen flash the new binary:
esptool.py --chip esp32s3 --port COM<N> --baud 921600 \
write_flash 0x220000 .pio/build/heltec_v4_companion_radio_ble/firmware.binThe custom patches to re-apply after a git pull are in two files:
src/helpers/esp32/SerialBLEInterface.cpp — BLE random static address (gives MeshCore a separate BLE MAC from Meshtastic so phone stores independent bonds):
- Derive
_rand_addrfrom efuse MAC with top 2 bits set to0xC0 - Call
pServer->getAdvertising()->setDeviceAddress(_rand_addr, BLE_ADDR_TYPE_RANDOM)before eachstart()
variants/heltec_v4/platformio.ini — partition table path, size override, BLE name:
board_build.partitions = <path>/partitions_dualboot.csv
board_upload.maximum_size = 5767168
-D BLE_PIN_CODE=123456
'-D BLE_NAME_PREFIX="MeshCore_"'examples/companion_radio/main.cpp — SPIFFS partition isolation:
SPIFFS.begin(true, "/spiffs", 10, "mc_spiffs");- MeshCore first boot — MeshCore shows a loading screen on first boot while initialising SPIFFS. Normal behaviour — connect via the MeshCore BLE companion app.
heltec-v4-dualboot/
├── README.md
├── partitions_dualboot.csv # 16 MB partition table
├── flash_all.py # convenience flash script
├── prebuilt/ # ready-to-flash binaries
│ ├── bootloader.bin # flash @ 0x0000
│ ├── partition-table.bin # flash @ 0x8000
│ ├── ota_data_initial.bin # flash @ 0x1c000
│ └── selector.bin # flash @ 0x20000
├── selector/ # selector app source (ESP-IDF)
│ ├── CMakeLists.txt
│ ├── sdkconfig.defaults
│ ├── partitions_dualboot.csv
│ ├── bootloader_components/
│ │ └── main/
│ │ ├── CMakeLists.txt
│ │ └── bootloader_start.c # extended GPIO0 window
│ └── main/
│ ├── CMakeLists.txt
│ └── selector_main.c
├── meshcore/
│ └── firmware.bin # MeshCore Companion Radio BLE @ 0x220000
└── meshtastic/
└── firmware.bin # Meshtastic 2.7.21 @ 0x680000
- Board: Heltec WiFi LoRa 32 V4 (ESP32-S3, 16 MB flash, 2 MB PSRAM)
- OLED: 128×64 SSD1315 (SSD1306-compatible) on I2C SDA=17, SCL=18, RST=21
- VEXT: GPIO36 (active LOW) — must be driven LOW to power the OLED
- PRG button: GPIO0 (active LOW, internal pull-up)
- RST button: Hardware reset
