Skip to content

pibot_pendant_v1_0_factory_app_main

The pibot_pendant_v1_0_factory_app_main module is the core logic layer of the PiBot Pendant v1.0 factory recovery application. It runs as an independent ESP-IDF firmware image flashed to the factory OTA partition and provides a standalone, self-contained recovery environment that operates entirely outside the main pendant firmware.

When the user holds BTN3 at power-on, the custom bootloader detects the press, backs up the OTA partition table (otadata) to a reserved flash sector, erases the live otadata (so the ESP-IDF ROM bootloader selects the factory partition), and performs a software reset. The factory app then starts, restores otadata from the backup, renders a navigable menu on the ILI9341 display, and lets the operator boot a specific OTA slot or flash new firmware / UI resources directly from a microSD card.

This module is the entry point and orchestrator of that recovery environment. It owns the top-level control flow, the menu state machine, all rendering calls, and all flash-write sequences.

Related modules:


The factory recovery app is a completely separate firmware image from the main pendant firmware. It lives in the boards/pibot_pendant_v1_0/Factory/ tree and is built independently. There are no shared code paths with the main firmware at runtime.

Flash Layout

Bootloader

0x0000

Partition Table

0xC000

NVS

0xD000

factory partition

(Factory App)

app0 / ota_0

(Main Firmware)

app1 / ota_1

(Main Firmware - 8MB only)

otadata

0x10000

ui_resources

(Custom Data Partition)

Backup Sector

0xB000 (pre-partition)

Storage Layer - pibot_pendant_v1_0_factory_app_storage

Input Layer - pibot_pendant_v1_0_factory_app_input

Display Layer - pibot_pendant_v1_0_factory_app_display

main.c

(pibot_pendant_v1_0_factory_app_main)

- app_main, menu FSM, OTA actions

gfx.c

- framebuffer, drawing primitives,

snapshot capture

ili9341.c

- SPI panel driver,

ILI9341 init sequence, flush

buttons.c

- GPIO polling, debounce

encoder.c

- PCNT quadrature decode

touch.c

- FT6336U I2C polling

buzzer.c

- GPIO tone generation

sdcard.c

- SPI+FAT VFS mount/unmount


The factory recovery lifecycle involves three distinct phases across the bootloader and the factory app. Understanding this handshake is essential before modifying either side.

SPI FlashFactory App(main.c)ROM BootloaderCustom Bootloader(hooks.c)UserSPI FlashFactory App(main.c)ROM BootloaderCustom Bootloader(hooks.c)UserPower-off at any point nowreturns to correct OTA slotHold BTN3 at power-onDetect BTN3 press (5-sample debounce)beep_short() - acknowledgementbackup_and_erase_otadata()• Read otadata sectors → 0xB000• Write magic 0xAA55AA55 at 0xB040• Erase live otadata (0x10000)beep_confirm() - successesp_rom_software_reset_system()Boot factory partition(otadata empty → factory selected)restore_otadata_from_backup()• Check magic at 0xB040• Read entries from 0xB000• Write back to 0x10000• Erase backup sectorDisplay recovery menu

Safety guarantee: restore_otadata_from_backup() runs as the very first action in app_main(), before any hardware is initialised. This ensures that even an immediate power-off after entering recovery returns the device to the previously active OTA slot.


File: boards/pibot_pendant_v1_0/Factory/main/main.c

app_main() is the top-level entry point called by the ESP-IDF startup task. It performs a strictly ordered initialisation sequence, builds the menu, then drives an event loop.

factory_log_silence_sd_stack()

restore_otadata_from_backup()

ili9341_init() + ili9341_backlight(true)

gfx_init()

buttons_init()

encoder_init()

touch_init()

buzzer_init()

Detect partitions

(app0 always, app1 if present)

Probe SD card files

probe_sd_files()

Build menu_items[]

draw_menu()

Main event loop

If ili9341_init() fails, app_main() calls abort() after a 200 ms UART flush delay — the display is a hard dependency; there is no fallback rendering path.

The loop polls all input sources with no RTOS blocking between sources (short 100 ms timeout on button_wait_press):

Loop forever

snapshot_check()

ENABLE_SNAPSHOT only

encoder_read()

enc>0 → menu_move(-1)

enc<0 → menu_move(+1)

button_wait_press(100ms)

→ dispatch_button()

touch_read()

Hit-test → dispatch_button()

Touch events include a visual and audible feedback sequence before calling dispatch_button():

  1. draw_button_hint_pressed(vbtn) — repaints the circle in violet
  2. buzzer_beep_short() — short tone
  3. 80 ms delay
  4. draw_button_hints() — restores normal colours
  5. dispatch_button(vbtn) — executes the action

Reads the bootloader backup written to 0xB000, validates the magic word 0xAA55AA55 at offset 0x40, writes the two otadata entries back to 0x10000 / 0x11000, then erases the backup sector so the restore does not repeat on any subsequent boot from this factory partition.

// Flash layout used by this function — must match bootloader hooks.c EXACTLY
#define OTADATA_OFFSET 0x10000 // Live otadata sector 1
#define OTADATA_SECTOR_SIZE 0x1000 // 4 KB per sector
#define OTADATA_ENTRY_SIZE 32 // Bytes per otadata entry
#define OTADATA_BACKUP_OFFSET 0xB000 // Reserved backup sector (pre-partition)
#define BACKUP_MAGIC_OFFSET 0x40 // Magic word offset within backup sector
#define BACKUP_MAGIC 0xAA55AA55

⚠️ Porting note: OTADATA_BACKUP_OFFSET must satisfy three constraints simultaneously:

  1. After bootloader end (~0x5980 for this board)
  2. Before partition table (CONFIG_PARTITION_TABLE_OFFSET = 0xC000)
  3. 4 KB-aligned

CONFIG_SPI_FLASH_DANGEROUS_WRITE_ALLOWED=y must be set in the factory sdkconfig because esp_flash_erase_region() targets an address below the first partition.

Looks up the requested partition label with esp_partition_find_first(), sets it as the boot partition via esp_ota_set_boot_partition(), and calls esp_restart(). On failure, redraws the menu with an error status.


typedef struct {
const char *label; // Display text
menu_action_t action; // BOOT_APP0 | BOOT_APP1 | SD_UPDATE_APP0 | SD_UPDATE_APP1 | SD_UPDATE_RES
uint16_t color; // RGB565 text colour when not selected
} menu_item_t;
static menu_item_t menu_items[MENU_MAX_ITEMS]; // Up to 8 entries
static int menu_count = 0; // Populated entries
static int menu_selected = 0; // Highlighted index

Menu items are always built from the same set; app1 entries appear only when the partition is detected:

Item LabelAction EnumShown When
Boot app0MENU_ACTION_BOOT_APP0Always
Boot app1MENU_ACTION_BOOT_APP1has_app1 == true
SD -> app0MENU_ACTION_SD_UPDATE_APP0Always
SD -> app1MENU_ACTION_SD_UPDATE_APP1has_app1 == true
SD -> resourcesMENU_ACTION_SD_UPDATE_RESAlways
FunctionDescription
menu_move(direction)Moves selection by ±1, wraps around at boundaries
menu_select(index)Sets menu_selected, clears status, redraws old and new items only
dispatch_button(btn)BTN1 → menu_move(-1), BTN2 → menu_move(+1), BTN3 → execute_selected_action()
execute_selected_action()Switches on menu_items[menu_selected].action and calls the appropriate action

The rendering system uses a direct scanline push approach: primitives compute pixel data into small stack buffers and write immediately to the ILI9341 via SPI. There is no retained LVGL scene graph or full-screen framebuffer in RAM.

┌──────────────────────────────────────┐ y=0
│ PiBot Recovery vX.X.X │ y=10 (title, centered)
│ ───────────────────────────────── │ y=30 (separator line)
│ Active: app0 │ y=40 (active OTA slot)
│ SD: FW RES │ y=61 (SD file indicators)
│ ───────────────────────────────── │ y=78 (separator line)
│ [ Boot app0 ] │ y=83 (menu item 0, MENU_START_Y)
│ > SD -> app0 < │ y=113 (selected item, highlighted)
│ [ SD -> resources ] │ y=143 (menu item 2)
│ ───────────────────────────────── │ STATUS_Y-5
│ Power off to cancel │ STATUS_Y (footer / status message)
│══════════════════════════════════════│ BTN_HINT_BASE_Y (separator line)
│ ↑ ↓ ✓ │ BTN_HINT_CY (button circles)
└──────────────────────────────────────┘ y=SCREEN_HEIGHT
FunctionWhat it draws
draw_header()Clears screen to black, white border, title string, first separator
draw_menu()Full-screen redraw: header + OTA info + SD indicators + all items + footer + hint bar
draw_menu_item(index)Single item: clears area, highlight box if selected, label in correct colour
draw_sd_indicators()SD: label + FW (green) and/or RES (cyan) if files detected on SD
draw_footer_zone()Status message or default “Power off to cancel” text, centered
draw_flashing_screen()Header + “Flashing…” title + “Do NOT power off!” warning
draw_progress(percent)Centered progress bar outline + proportional fill + percentage text
draw_result(success, msg)Success (green msg) or failure (red “FAILED! Power cycle.”) in lower area
draw_button_hints()Separator line + three circle-icon buttons in the hint bar
draw_button_hint_at(cx, cy, btn, color)One double-stroke circle + centred icon at given pixel position
draw_button_hint_pressed(btn)Recolours one button to BTN_PRESSED_COLOR (violet) for press feedback

Icons are drawn with direct gfx_hline() calls (no font or bitmap lookup). All icon functions take (cx, cy) as the icon centre, designed to fit inside a radius-20 circle:

draw_circle(cx,cy,r,color)

Midpoint algorithm

Double-stroke outline

draw_up_arrow(cx,cy,color)

7-row arrowhead + 10-row stem

draw_down_arrow(cx,cy,color)

10-row stem + 7-row arrowhead

draw_check_mark(cx,cy,color)

×2 scale, 3px thick, junction row

draw_button_hint_at()

circle + icon composite

static char last_status_msg[40] = "";
static uint16_t last_status_color = 0;
  • show_status(msg, color) — stores message, redraws only the footer zone
  • clear_status() — clears message, restores “Power off to cancel” default
  • draw_footer_zone() — idempotent redraw driven by last_status_msg

probe_sd_files() mounts the SD card, checks for esp3dfw.bin and ui_resources.bin, then unmounts. The results (sd_has_fw, sd_has_res) are module-level booleans used by draw_sd_indicators().

/sdcard/
├── esp3dfw.bin ← detected → sd_has_fw = true
├── esp3dfw.ok ← renamed from esp3dfw.bin on successful flash
├── esp3dfw.bad ← renamed from esp3dfw.bin on failed flash
├── ui_resources.bin ← detected → sd_has_res = true
├── ui_resources.ok ← renamed on successful flash
└── ui_resources.bad ← renamed on failed flash

SD update menu items are always shown regardless of file detection. Detection only governs the indicator line at y=61. The operator can insert an SD card after entering recovery and use the items immediately.


Firmware Flash Action — action_sd_update

Section titled “Firmware Flash Action — action_sd_update”

Flashes esp3dfw.bin from SD to the target OTA partition using the ESP-IDF OTA API (esp_ota_begin / esp_ota_write / esp_ota_end).

more data

all written

fail

fail

fail

fail

fail

fail

fail

Find target partition

esp_partition_find_first()

Mount SD card

sdcard_mount()

fopen('/sdcard/esp3dfw.bin')

Validate size

0 < size <= partition size

draw_flashing_screen()

esp_ota_begin(update_part, fw_size)

Read 1KB chunk

fread(buf, 1024)

esp_ota_write(ota_handle, buf, len)

draw_progress(percent)

esp_ota_end(ota_handle)

esp_ota_set_boot_partition(update_part)

draw_result(true, 'Success! Rebooting...')

rename → esp3dfw.ok

sdcard_unmount()

2s delay

esp_restart()

show_status error

return

show_status error

return

ok = false

break

esp_ota_abort()

draw_result(false)

rename → esp3dfw.bad

3s delay

draw_menu()

The 1 KB read/write chunk is a stack buffer — no heap allocation is needed in the hot path.


Resource Flash Action — action_sd_update_res

Section titled “Resource Flash Action — action_sd_update_res”

Flashes ui_resources.bin from SD to the ui_resources custom data partition using direct partition API (esp_partition_erase_range + esp_partition_write). The OTA API is not used because this is not an app partition.

Key differences from action_sd_update:

  • Reads a 16-byte build header (ESP3 magic + 12-char variant string) at file offset 0 and logs it before flashing, enabling early detection of variant mismatches.
  • Uses esp_partition_erase_range() (full partition erase) before writing.
  • Target partition is looked up by name "ui_resources" and type ESP_PARTITION_TYPE_DATA.

more data

all written

success

fail

Find 'ui_resources' data partition

Mount SD

fopen('/sdcard/ui_resources.bin')

Read 16-byte header

Log variant string if 'ESP3' magic present

Validate size

0 < size <= partition size

draw_flashing_screen()

esp_partition_erase_range(res_part, 0, res_part->size)

Read 1KB chunk

esp_partition_write(res_part, offset, buf, len)

draw_progress(percent)

draw_result(ok, 'Done! Rebooting...')

rename → ui_resources.ok

esp_restart()

rename → ui_resources.bad

3s delay

draw_menu()


The virtual button bar occupies the bottom BTN_HINT_H pixels of the screen. The hit-test zone is deliberately larger than the drawn circles: the full screen width is divided into three equal columns so any tap in the lower third registers correctly.

Screen width (240 px)
┌──────────┬──────────┬──────────┐
│ x < 80 │ 80–159 │ x ≥ 160 │
│ BTN_1 │ BTN_2 │ BTN_3 │
│ (↑) │ (↓) │ (✓) │
└──────────┴──────────┴──────────┘
y ≥ BTN_HINT_BASE_Y only

touch_hint_hit_test(x, y) returns BTN_NONE when y < BTN_HINT_BASE_Y (touches above the hint bar are ignored by this function and flow back to the menu for future touch-to-select support).


Snapshot System — ENABLE_SNAPSHOT (optional)

Section titled “Snapshot System — ENABLE_SNAPSHOT (optional)”

When compiled with ENABLE_SNAPSHOT, pressing the BOOT button (GPIO0) captures a raw screenshot to the SD card at any point during execution.

No

Yes

released

still 0

snapshot_check()

called from main loop and inside flash actions

GPIO0 == 0?

return (non-blocking)

50ms debounce

re-check GPIO0

sdcard_mount()

Find next snap###.raw

(scan existing files on first call)

gfx_snapshot_begin('/sdcard/snap000.raw')

• Write 8-byte header: width + height

• Pre-fill file body with zeros

Full screen redraw

s_snap_in_flash==false → draw_menu()

s_snap_in_flash==true → draw_flashing_screen()

+ draw_progress(s_flash_last_percent)

gfx_snapshot_end()

fclose, s_snap_file = NULL

Wait GPIO0 release + 50ms debounce

The snapshot pipeline is transparent: gfx_flush() mirrors every SPI pixel write to the open snapshot file via snap_write(), which seeks to the correct file offset for each rectangular region (x0,y0 → x1,y1) and writes native-endian RGB565 pixels.

Raw file format:

  • Bytes 0–3: screen width (little-endian uint32_t)
  • Bytes 4–7: screen height (little-endian uint32_t)
  • Bytes 8+: row-major RGB565 pixels (native endian), width × height × 2 bytes total

Use boards/pibot_pendant_v1_0/Factory/tools/raw2png/snap2png.py to convert .raw files to PNG for inspection.


The FACTORY_LOGD macro is a compile-time gate controlled by the ENABLE_FACTORY_DEBUG_LOG CMake option:

#if FACTORY_LOG_LEVEL // 1 when ENABLE_FACTORY_DEBUG_LOG=ON, 0 otherwise
#define FACTORY_LOGD(tag, fmt, ...) ESP_LOGI(tag, fmt, ##__VA_ARGS__)
#else
#define FACTORY_LOGD(tag, fmt, ...) do {} while (0) // Zero overhead in production
#endif

factory_log_silence_sd_stack() is called as the first statement in app_main(). It mutes eight ESP-IDF internal driver tags at runtime (sdmmc, vfs_fat_sdmmc, sdmmc_periph, sdmmc_req, sdmmc_common, fatfs, sdspi, sd_diskio) to prevent SD stack chatter from polluting serial output, particularly in debug builds where the sdkconfig log ceiling is raised to INFO.

ESP_LOGW and ESP_LOGE are never gated — they always emit regardless of FACTORY_LOG_LEVEL.


Outputs

Factory App Main (this module)

Inputs

Physical Buttons

BTN1 / BTN2 / BTN3

GPIO, active-LOW

Rotary Encoder

PCNT quadrature

Capacitive Touch

FT6336U over I2C

SD Card

SPI + FAT VFS

GPIO0 (BOOT)

Snapshot trigger

restore_otadata_from_backup()

First action in app_main

dispatch_button()

Unified input handler

Menu FSM

menu_move / menu_select

execute_selected_action

OTA Flash Actions

action_sd_update()

action_sd_update_res()

Rendering

draw_menu / draw_flashing_screen

draw_progress / draw_result

ILI9341 Display

240×320 RGB565 via SPI

SPI Flash

OTA partitions

ui_resources partition

otadata sector

Buzzer

Feedback tone on touch

SD: snap###.raw

Screenshot files


ConstantValuePurpose
OTADATA_OFFSET0x10000Live OTA data flash address (sector 1)
OTADATA_BACKUP_OFFSET0xB000Bootloader backup sector address
BACKUP_MAGIC0xAA55AA55Sentinel validating backup presence
FW_FILENAME/sdcard/esp3dfw.binFirmware update source file
RES_FILENAME/sdcard/ui_resources.binResource update source file
MENU_START_Y83Y-coordinate of first menu item
MENU_ITEM_H30Height per menu item in pixels
STATUS_YSCREEN_HEIGHT − 25 − BTN_HINT_HFooter status message Y-position
BTN_CIRCLE_R20Radius of button hint circles
BTN_HINT_H49Total height of button hint bar in pixels
MENU_MAX_ITEMS8Maximum menu entries
BTN_HINT_CX1/CX2/CX345 / 120 / 195Circle X-centres for BTN1 / BTN2 / BTN3

The factory app is built separately from the main firmware. Flash tooling lives in pibot_pendant_v1_0_factory_app_tools.

Terminal window
# Build the factory app
idf.py -C boards/pibot_pendant_v1_0/Factory build
# Flash only factory partition + custom bootloader + partition table
python boards/pibot_pendant_v1_0/Factory/tools/flash_factory.py
# Flash everything (factory + main firmware + resources)
python boards/pibot_pendant_v1_0/Factory/tools/flash_all.py

CMake feature flags (set in Factory/CMakeLists.txt):

FlagEffect
ENABLE_FACTORY_DEBUG_LOGEnables FACTORY_LOGD → ESP_LOGI output (FACTORY_LOG_LEVEL=1)
ENABLE_SNAPSHOTCompiles in GPIO0 screenshot capture via snapshot_check()

DecisionRationale
restore_otadata_from_backup() runs before any hardware initGuarantees safe power-off state even if the display fails to initialise
1 KB stack buffer for flash read/write chunksNo heap allocation; safe under worst-case fragmented ESP32 heap in factory context
SD items always visible regardless of file probeOperator can insert an SD card after entering recovery without re-running detection
Touch hit-target is full column width (80 px), not drawn circle (40 px)Easier to tap accurately under stress or without precise finger placement
button_wait_press(100ms) non-blocking timeoutKeeps encoder polling responsive — a longer block would drop encoder ticks
Rename source file to .ok / .bad after flashLets operator distinguish a used update file from a fresh one without a PC
CONFIG_SPI_FLASH_DANGEROUS_WRITE_ALLOWED=y requiredFactory app intentionally writes to the pre-partition region; this is a privileged recovery tool
SD chatter silenced at runtime via factory_log_silence_sd_stack()Debug builds raise the sdkconfig log ceiling to INFO, causing the SD stack to become verbose — silencing keeps factory log output focused on recovery logic