Skip to content

Factory Bootloader Module

The factory_bootloader module implements custom ESP-IDF 2nd-stage bootloader hooks that enable hardware-triggered factory recovery mode on all supported board variants. When a designated recovery button is held during power-on, the bootloader transparently backs up the OTA partition selection metadata (otadata), erases it to force the next boot into the factory application, and issues a software reset — all before the user application ever executes.

The factory application then restores the backed-up otadata during its own startup, ensuring that a power-off from within the recovery menu returns the device to the correct OTA firmware on the next regular boot.

This module is the entry point of the factory recovery system. It works in concert with:

  • factory_app — the recovery user interface (flashing, partition selection, SD update)
  • factory_tools — flash scripts that deploy the complete factory firmware bundle
  • build_system — per-board variant build pipelines

Power-On / Reset

Button NOT held

Button held

→ backup + erase otadata

→ software reset

User selects OTA boot

ROM Bootloader

ESP-IDF 2nd-Stage Bootloader

(custom hooks linked in)

bootloader_before_init()

- reserved, currently no-op

bootloader_after_init()

- recovery trigger detection

Read otadata

Select OTA partition

Normal OTA Application

(app0 / app1)

Bootloader re-runs

otadata empty → selects factory

Factory Application

(factory partition)

restore_otadata_from_backup()

- first call in app_main

Recovery Menu

(flash / boot / SD update)

Key insight: The bootloader and the factory app cooperate via a single shared flash sector (the backup sector). The bootloader writes to it; the factory app reads and clears it. No IPC or shared RAM is involved — only flash.


Each board delivers the bootloader hooks in two parallel locations within its Factory/ tree:

boards/<BOARD>/Factory/
├── bootloader_components/
│ └── custom_bootloader/
│ └── hooks.c ← ESP-IDF bootloader component integration point
└── custom_bootloader/
└── hooks.c ← Source-directory copy (used by some build configurations)

Both files are functionally identical for a given board. The bootloader_components/ path is the canonical integration path: ESP-IDF automatically detects and links any component placed there when building the bootloader.

BoardModule tagRecovery buttonBuzzer feedback
ESP32_S3_WROOM_CAMfactory_bootloader (this module)GPIO0 (BOOT button)No
pibot_pendant_v1_0pibot_pendant_v1_0_bootloaderBTN3Yes

All other boards in the project (esp32_3248s035c, esp32s3_8048s043c, etc.) share the same recovery mechanism but implement it only in the factory_app; they do not ship a custom bootloader component. The bootloader module is board-specific and compiled separately from the main firmware.


PiBot Pendant v1.0 variant only

ESP-IDF ROM / HAL - bootloader context only

hooks.c - custom bootloader

linker symbol

keeps component linked

bootloader_hooks_include()

bootloader_before_init()

bootloader_after_init()

is_button_pressed()

backup_and_erase_otadata()

gpio_ll_*

esp_rom_gpio_*

esp_rom_spiflash_*

esp_rom_delay_us()

esp_rom_software_reset_system()

ESP_LOGx()

buzzer_tone()

beep_short()

beep_confirm()

beep_error()

Bootloader context constraints: The custom hooks run inside the ESP-IDF 2nd-stage bootloader. No FreeRTOS, no heap allocator, no peripheral drivers — only ROM functions and HAL register-level GPIO / SPI-flash APIs are available.


Yes

No

Not pressed

Pressed

Yes

No

≥ 5 s still held

Released within 5 s

Error on any step

Yes

No

OK

Yes

No

bootloader_after_init called

Configure recovery button GPIO:

pad select · input enable · pull-up enable

→ 100 ms stabilization delay

PiBot variant?

gpio_ll_output_enable

BUZZER_PIN

is_button_pressed()?

5 samples × 5 ms

≥ 3 of 5 LOW = pressed

Return - normal boot continues

PiBot?

beep_short()

short acknowledgement tone

Poll button release

10 ms intervals, max 5 seconds

Timeout?

Abort - return to normal boot

100 ms delay

backup_and_erase_otadata()

Success?

PiBot?

beep_error()

error tone

Return - normal boot

PiBot?

beep_confirm()

2700 Hz → 3200 Hz two-tone

200 ms delay

esp_rom_software_reset_system()

Reboot - bootloader selects factory

is_button_pressed() takes five GPIO samples 5 ms apart and returns true only if at least three of them read LOW. This 25 ms window filters electrical noise on the input line without requiring hardware debounce circuitry.

static bool is_button_pressed(int pin) {
int pressed_count = 0;
for (int i = 0; i < 5; i++) {
if (gpio_ll_get_level(&GPIO, pin) == 0) { pressed_count++; }
esp_rom_delay_us(5000);
}
return pressed_count >= 3; // majority vote
}

0x0000 - 0x8FFF

Bootloader - ROM + 2nd stage

Custom hooks linked here

0x8000

Partition Table

0x9000 - 0xCFFF

NVS - main application NVS

0xD000 - 0xEFFF

NVS backup region - sectors A & B

⭐ 0xF000 - 0xFFFF ← BACKUP SECTOR

[entry1 32B] [entry2 32B] [magic 4B] [0xFF rest]

Written by bootloader, cleared by factory app

0x10000 - 0x11FFF ← otadata (2 × 4 KB)

Erased by bootloader → forces factory partition selection

factory partition

Recovery application

ota_0 (app0)

Normal firmware

ota_1 (app1)

Alternate firmware - if present

OffsetSizeContent
0x00032 Botadata entry 1 — copy of sector 1
0x02032 Botadata entry 2 — copy of sector 2
0x0404 BMagic word 0xAA55AA55 — valid backup marker
0x044rest0xFF — erased, unused

The magic word is the sentinel checked by the factory app on startup. If absent or wrong, no restore is attempted and the factory app runs normally (e.g., user launched it via a partition boot command rather than through the recovery trigger).


OTA ApplicationFactory AppSPI FlashCustom BootloaderUserOTA ApplicationFactory AppSPI FlashCustom BootloaderUserPhase 1 - Recovery triggered (button held at power-on)Bootloader re-runs - empty otadata → selects factory partitionPhase 2 - Factory app startup (restore before any UI)Phase 3 - Recovery menu interactionalt[User flashes new firmware via SD card][User selects a partition to boot]Backup sector cleared - no spurious restore on next power-onHold recovery button at power-onRead otadata[0x10000] → entry1 (32 B)Read otadata[0x11000] → entry2 (32 B)Erase backup sector [0xF000]Write [entry1 | entry2 | 0xAA55AA55] at 0xF000Erase otadata sector 1 [0x10000]Erase otadata sector 2 [0x11000]esp_rom_software_reset_system()Boot Factory ApplicationRead magic at 0xF0400xAA55AA55 → valid backup confirmedRead entry1 from 0xF000 (32 B)Read entry2 from 0xF020 (32 B)Erase otadata sector 1 [0x10000]Erase otadata sector 2 [0x11000]Write entry1 → 0x10000Write entry2 → 0x11000Erase backup sector [0xF000] ← clears markerShow recovery menuSelect "SD → app0" / "SD → app1"Write new firmware to OTA partitionSelect "Boot app0" / "Boot app1"esp_restart() → bootloader reads restored otadata → boots correct OTA app
void bootloader_hooks_include(void);

Empty function whose sole purpose is to prevent the linker from discarding the custom bootloader component. ESP-IDF’s bootloader link step silently drops object files with no referenced symbols. This function is declared extern in the ESP-IDF bootloader hook header; referencing it by name pulls the entire translation unit into the link.


void bootloader_before_init(void);

Called by ESP-IDF’s 2nd-stage bootloader before any hardware peripheral initialization. Currently a no-op, reserved for future use such as early clock configuration or power-rail sequencing.


void bootloader_after_init(void);

Called by the bootloader after all standard hardware initialization is complete. This is the primary recovery trigger entry point. Steps:

  1. Configures the recovery button GPIO as input with internal pull-up.
  2. Optionally enables the buzzer GPIO output (PiBot variant only).
  3. Waits 100 ms for the pull-up voltage to stabilize.
  4. Samples the button with is_button_pressed().
  5. If not pressed → returns immediately (normal boot path continues).
  6. If pressed → waits for release (max 5 s), then calls backup_and_erase_otadata() and issues esp_rom_software_reset_system().

static bool is_button_pressed(int pin); // ESP32-S3-WROOM-CAM variant
static bool is_button_pressed(gpio_num_t pin); // PiBot Pendant v1.0 variant

Returns true if the given GPIO pin reads LOW in at least 3 of 5 samples taken 5 ms apart. Active-LOW button logic; the GPIO must be configured as input with pull-up before calling.


static void backup_and_erase_otadata(void);

Core flash manipulation routine using ROM SPI-flash APIs (available in bootloader context). Operations performed in order:

StepROM APIFlash address
Read otadata entry 1esp_rom_spiflash_read0x10000 (32 B)
Read otadata entry 2esp_rom_spiflash_read0x11000 (32 B)
Erase backup sectoresp_rom_spiflash_erase_sectorsector 0xF → 0xF000
Write backup + magicesp_rom_spiflash_write0xF000 (4096 B)
Erase otadata sector 1esp_rom_spiflash_erase_sectorsector 0x10 → 0x10000
Erase otadata sector 2esp_rom_spiflash_erase_sectorsector 0x11 → 0x11000

Each step is independently checked. On any failure the function logs the error and (on PiBot) emits beep_error(), then returns early — leaving otadata intact and allowing a clean normal boot.


These functions exist only in the pibot_pendant_v1_0_bootloader variant. They use direct GPIO bit-banging — no hardware timer, no PWM peripheral — which is compatible with the bootloader execution environment.

static void buzzer_tone(uint32_t frequency, uint32_t duration_ms);

Generates a square wave on BUZZER_PIN by toggling the GPIO at the requested frequency for the requested duration. Half-period = 1 000 000 / (2 × frequency) µs. Timing via esp_rom_delay_us().

Single short tone played when the recovery button press is first confirmed. Gives the user immediate audible acknowledgement that the gesture was recognized.

Two-tone rising sequence — 2700 Hz then 3200 Hz with a 100 ms gap — played after otadata has been successfully backed up and erased. Signals that the reset-to-factory sequence is committed.

Error tone played on any flash read/write failure inside backup_and_erase_otadata(). Signals that the recovery sequence could not complete and the system will fall back to a normal boot.


PropertyESP32_S3_WROOM_CAMpibot_pendant_v1_0
Module tagfactory_bootloaderpibot_pendant_v1_0_bootloader
Recovery buttonGPIO0 (BOOT, active LOW)BTN3 (GPIO, active LOW)
BuzzerNoneYes — GPIO bit-bang
Beep on button press—beep_short()
Beep on successful backup—beep_confirm()
Beep on flash error—beep_error()
Display during recoveryNone (headless board)ILI9341 (via factory app)
Partition CSVpartitions_16mb.csvBoard-specific CSV

Defined in each board’s hooks.c. Must stay synchronized with the board’s partition table CSV.

ConstantValueDescription
FLASH_SECTOR_SIZE0x1000Flash erase unit — 4096 B
BOOT_BUTTON_PIN0 (WROOM-CAM)GPIO pin for recovery trigger
OTADATA_OFFSET0x10000otadata partition start address
OTADATA_SECTOR_10x10Flash sector index — otadata entry 1
OTADATA_SECTOR_20x11Flash sector index — otadata entry 2
OTADATA_BACKUP_OFFSET0xF000Backup sector start address
OTADATA_BACKUP_SECTOR0xFFlash sector index — backup
OTADATA_ENTRY_SIZE32Size of one otadata entry in bytes
BACKUP_MAGIC_OFFSET0x40Offset of magic word within backup sector
BACKUP_MAGIC0xAA55AA55Valid-backup sentinel value
RELEASE_TIMEOUT_US5 000 000Max wait for button release (5 s)
POLL_INTERVAL_US10 000Button polling period (10 ms)

To add factory bootloader recovery to a new board:

  1. Create the component directory:

    boards/<NEW_BOARD>/Factory/bootloader_components/custom_bootloader/
  2. Copy hooks.c from the closest existing variant and update:

    • The recovery button GPIO constant to match the board hardware.
    • OTADATA_OFFSET and OTADATA_BACKUP_OFFSET to match the new board’s partition CSV.
    • Add buzzer_tone() / beep_*() helpers if the board has a buzzer.
  3. Add CMakeLists.txt in custom_bootloader/:

    idf_component_register(SRCS "hooks.c" INCLUDE_DIRS ".")
  4. Verify the partition CSV — the backup sector address must not overlap any application-defined partition.

  5. Update the factory app (main.c) to use the same OTADATA_BACKUP_OFFSET and BACKUP_MAGIC values so restore_otadata_from_backup() can locate the backup written by the bootloader.

  6. Deploy using the board’s factory tools scripts — see factory_tools for the flash_all.py and flash_factory.py helpers.


PropertyHow it is guaranteed
No data loss on button held too longRelease timeout (5 s): if not released, backup_and_erase_otadata() is never called
No data loss on flash errorEach flash operation is independently checked; function returns early before otadata is erased
Idempotent factory bootBackup sector is cleared by the factory app after a successful restore; re-entering factory without a button press finds no valid magic and skips the restore
No FreeRTOS / heap dependencyOnly ROM SPI-flash and ROM delay functions are used — valid in bootloader context
Partition alignmentConstants in hooks.c must mirror the CSV; mismatches are caught during board bring-up