Skip to content

Factory Visual Feedback Module

The factory_visual_feedback module is the display rendering layer for the factory recovery application. It owns every pixel that communicates operation state to the operator: the “Flashing…” warning screen, the real-time progress bar, the success/failure result banner, the SD card file indicators in the menu header, and the circle/checkmark primitives used by the button hint bar.

It is intentionally thin — it contains no state of its own. All decisions about when to draw are made by sibling modules (factory_menu_system, factory_update_actions); this module only decides how to paint.


  1. Module Position in the Factory App
  2. Component Architecture
  3. Screen Zone Layout
  4. Function Reference
  5. Board-Specific Scaling
  6. Flashing Operation Data Flow
  7. Graphics Layer Dependency
  8. Snapshot Integration
  9. Porting Checklist

The factory recovery application (boards/<board>/Factory/main/main.c) is compiled as a standalone ESP-IDF app that occupies the factory OTA partition. It is entered when the main firmware executes [ESP444]FACTORY or (on the PiBot Pendant) when the BOOT button is held during power-on via the custom bootloader.

factory_visual_feedback is one of six logical modules that live inside main.c:

Support Modules

Factory Application (main.c)

factory_app_entry

(app_main)

factory_menu_system

(draw_menu, menu_move …)

factory_update_actions

(action_sd_update …)

factory_visual_feedback

← this module →

factory_snapshot

(snapshot_take …)

factory_input_dispatch

(dispatch_button …)

factory_graphics

(gfx.c)

Board LCD Driver

(st7796 / st7262 / ili9341 …)

factory_sdcard

(sdcard.c)

factory_visual_feedback sits between the business logic (update actions, menu system) and the raw pixel-pushing layer (factory_graphics / gfx.c). It has no upward dependencies on FreeRTOS tasks or OTA APIs.


calls gfx_* primitives

calls draw_circle\ndraw_check_mark\ndraw_sd_indicators

calls draw_flashing_screen\ndraw_progress\ndraw_result

calls gfx_* primitives directly

factory_visual_feedback

+draw_flashing_screen()

+draw_progress(int percent)

+draw_result(bool success, const char* msg)

+draw_sd_indicators()

+draw_circle(int cx, int cy, int r, uint16_t color)

+draw_check_mark(int cx, int cy, uint16_t color)

factory_menu_system

+draw_menu()

+draw_header()

+draw_menu_item(int index)

+draw_footer_zone()

+draw_button_hints()

+draw_button_hint_at(int cx, int cy, btn, color)

+draw_button_hint_pressed(button_id_t btn)

+draw_up_arrow(int cx, int cy, uint16_t color)

+draw_down_arrow(int cx, int cy, uint16_t color)

factory_graphics

+gfx_hline(x, y, w, color)

+gfx_vline(x, y, h, color)

+gfx_fill_rect(x, y, w, h, color)

+gfx_rect(x, y, w, h, color)

+gfx_draw_string(x, y, str, fg, bg)

+gfx_clear(color)

+gfx_flush(x0, y0, x1, y1, buf, n)

+gfx_snapshot_begin(filepath)

+gfx_snapshot_end()

factory_update_actions

+action_sd_update(target_label)

+action_sd_update_res()

+action_boot_partition(label)

Key design principle: draw_circle and draw_check_mark live in factory_visual_feedback even though they are consumed by factory_menu_system’s draw_button_hint_at. This reflects ownership — the confirm-mark icon is conceptually a “result” symbol. The navigation arrows (draw_up_arrow, draw_down_arrow) belong to menu navigation and therefore live in factory_menu_system.


The recovery UI uses a consistent zone map across all boards. Absolute pixel coordinates vary per board (see Board-Specific Scaling), but the zone hierarchy is always:

┌──────────────────────────────────────────────┐
│ ┌────────────────────────────────────────┐ │ ← double-border (gfx_rect × 2)
│ │ Recovery VERSION (CYAN) │ │ y = 13
│ │ ───────────────────────────────────── │ │ y = 40 (separator)
│ │ Active OTA partition label (YELLOW) │ │ y = 53
│ │ SD: FW RES ← draw_sd_indicators() │ │ y = 81
│ │ ───────────────────────────────────── │ │ y = 104 (separator)
│ │ │ │
│ │ ▶ Menu item 0 (WHITE) │ │ MENU_START_Y
│ │ Menu item 1 (GRAY) │ │ + MENU_ITEM_H
│ │ Menu item 2 (GRAY) │ │ + MENU_ITEM_H × 2
│ │ │ │
│ │ ───────────────────────────────────── │ │ STATUS_Y − 7
│ │ Status text (YELLOW) │ │ STATUS_Y
│ │ ───────────────────────────────────── │ │ BTN_HINT_BASE_Y
│ │ ↑ ↓ ✓ │ │ BTN_HINT_CY
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘

When draw_flashing_screen() is called the header is preserved but everything below is replaced:

┌──────────────────────────────────────────────┐
│ Recovery VERSION (CYAN) │ ← draw_header() preserved
│ ─────────────────────────────────────────── │
│ │
│ Flashing firmware... (YELLOW) │ ← SCREEN_HEIGHT/2 − 13
│ Do NOT power off! (RED) │ ← SCREEN_HEIGHT/2 + 7
│ │
│ ┌─────────────────────────────────────────┐ │ ← draw_progress()
│ │ ███████████░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ y = SCREEN_HEIGHT/2 + 27
│ └─────────────────────────────────────────┘ │ height = 27 px
│ 42% │
│ │
│ Success! Rebooting... (GREEN) │ ← draw_result()
│ — OR — │ y = SCREEN_HEIGHT/2 + 93
│ FAILED! Power cycle. (RED) │
└──────────────────────────────────────────────┘
ColourSemantic use
CYANTitle / version label
YELLOWActive partition label, operation title
GREEN"FW" SD indicator, progress bar fill, success result
CYAN"RES" SD indicator
WHITEMenu items (non-selected), progress bar outline
GRAYMenu items (de-selected)
BLUENavigation button hints (↑ ↓)
GREENConfirm button hint (✓)
VIOLETPressed button visual feedback
RED"Do NOT power off!" warning, failure result

static void draw_circle(int cx, int cy, int r, uint16_t color);

Draws a 1-pixel-wide circle outline centred at (cx, cy) with radius r using the Bresenham midpoint algorithm. Each iteration emits eight symmetric gfx_hline() calls of width 1, producing an octant-mirrored circle with no floating-point arithmetic and no per-pixel LCD flush overhead beyond what gfx_hline’s line-buffer batching already provides.

Used by draw_button_hint_at() (in factory_menu_system) to draw a double circle outline (BTN_CIRCLE_R and BTN_CIRCLE_R − 1), giving a 2-pixel-thick ring around each virtual button icon.

ParameterDescription
cx, cyCentre of the circle in logical screen coordinates
rRadius in pixels
colorRGB565 foreground colour

static void draw_check_mark(int cx, int cy, uint16_t color);

Draws a ✓ checkmark centred at (cx, cy) using a series of gfx_hline() calls. The mark has a 4-pixel stroke width rendered in two arms:

  • Left arm — short, descends from upper-left to the vertex
  • Right arm — long, ascends from the vertex to upper-right
  • Junction row — closes the vertex cleanly between the two arms

Used exclusively by draw_button_hint_at() for the BTN_3 (OK / confirm) icon. Pixel coordinates are scaled proportionally to BTN_CIRCLE_R across board variants.


static void draw_flashing_screen(void);

Draws the full-screen warning layout shown whenever the factory app writes to flash:

  1. Calls draw_header() (owned by factory_menu_system) — clears the canvas, paints title row and separators.
  2. Renders the operation title string (s_flash_title, set by the caller) in YELLOW at SCREEN_HEIGHT/2 − 13.
  3. Renders "Do NOT power off!" in RED at SCREEN_HEIGHT/2 + 7.

The caller sets the title before calling:

s_flash_title = "Flashing firmware...";
draw_flashing_screen();

⚠️ Clears the entire screen. Must only be called at the start of a destructive flash operation.


static void draw_progress(int percent);

Draws or updates a green progress bar with a white outline and a percentage label. The bar outline is always redrawn in full — this ensures snapshot captures reproduce a complete, consistent frame regardless of when they fire.

ElementPosition
Bar outline (WHITE)y = SCREEN_HEIGHT/2 + 27, h = 27 px, w = SCREEN_WIDTH − 53
Green fill3 px inset inside outline; width proportional to percent
Percentage label (WHITE)y = bar_y + bar_h + 11, centred; text area cleared before redraw

Called inside the SD read-write loop on each percent change:

int percent = (int)((written * 100) / total_size);
if (percent != last_percent) {
draw_progress(percent);
last_percent = percent;
}

Performance note: At most 100 calls per flash operation. Each call performs 2–3 filled-rectangle operations through gfx.c’s line-buffer batching — acceptable for the factory app, which runs without LVGL or any concurrent UI task.


static void draw_result(bool success, const char *success_msg);

Draws the final outcome of a flash operation at y = SCREEN_HEIGHT/2 + 93, clearing that zone first.

successRendered textColour
truesuccess_msg (e.g. "Success! Rebooting...")GREEN
false"FAILED! Power cycle." (hardcoded)RED

Called once per flash attempt after the OTA or partition write loop completes.


static void draw_sd_indicators(void);

Draws SD card file availability indicators in the header zone. The state variables sd_has_fw and sd_has_res are set by probe_sd_files() in factory_update_actions; this function only reads them.

Layout (right-aligned in the header):

SD: FW RES
(DARK GRAY label) (GREEN) (CYAN)
  • "FW" shown in GREEN when /sdcard/esp3dfw.bin exists.
  • "RES" shown in CYAN when /sdcard/ui_resources.bin exists.
  • Tags are omitted individually when the corresponding file is absent.

Called from draw_menu() (in factory_menu_system) each time the menu redraws.


All six board implementations share the same drawing logic but adapt pixel coordinates via compile-time constants. There are three scaling families relative to a 240×320 portrait reference:

× 4/3

× 0.85

× 1.5

Large - 800 × 480 / 480 × 272 logical

esp32s3_8048_touch_lcd_7

esp32s3_8048s043c

esp32s3_8048s050c

Font 12×24 | BTN_CIRCLE_R = 30

(× 1.5 scale)

Compact - 272 × 480 logical portrait

esp32s3_4827s043c

(480×272 glass, rotated)

Font 8×16 | BTN_CIRCLE_R = 22

(× 0.85 scale)

Medium - 320 × 480 portrait

esp32_3248s035c

esp32_3248s035r

Font 11×21 | BTN_CIRCLE_R = 27

(× 4/3 scale)

Reference - 240 × 320 portrait

pibot_pendant_v1_0

esp32_2432s028r

Font 8×16 | BTN_CIRCLE_R = 20

Constant240×320 ref320×480 (3248s035)272×480 (4827s043)800×480 (8048-family)
FONT_WIDTH811812
FONT_HEIGHT16211624
BTN_CIRCLE_R20272230
MENU_START_Y831119494
MENU_ITEM_H30403434

The touch_hint_hit_test() function maps a tap coordinate to one of three button zones. Portrait-native and landscape-rotated boards use different strategies:

yes

no

Portrait-native

(3248s035c/r, 4827s043c)

Rotated landscape

(8048s043c, 050c, lcd7)

touch_hint_hit_test(x, y)

y < BTN_HINT_BASE_Y?

return BTN_NONE

Screen orientation family

Divide logical width by 3

Column 0 → BTN_1 (↑)

Column 1 → BTN_2 (↓)

Column 2 → BTN_3 (✓)

Use icon-centre midpoints

boundary_1_2 = (CX1 + CX2) / 2

boundary_2_3 = (CX2 + CX3) / 2

Avoids wrong-column mapping

on wide logical canvases

Why the difference? On 8048-family boards the logical canvas can be 800 px wide, but the three button icons are clustered within ±100 px of screen centre. Dividing the full width into thirds would place BTN_1 and BTN_3 in the wrong column. Using the midpoint between actual icon centres gives correct hit zones regardless of canvas width.


LCD Driverfactory_graphics (gfx.c)factory_visual_feedbackfactory_update_actionsLCD Driverfactory_graphics (gfx.c)factory_visual_feedbackfactory_update_actionsalt[percent changed]loop[Read 1 024-byte chunks from SD]Set s_flash_title stringdraw_flashing_screen()gfx_clear(BLACK) via draw_header()gfx_draw_string("Flashing…", YELLOW)gfx_draw_string("Do NOT power off!", RED)SPI / I80 / RGB flushesp_ota_write() or esp_partition_write()draw_progress(percent)gfx_rect(bar outline, WHITE)gfx_fill_rect(fill, GREEN)gfx_draw_string("42%", WHITE)flushdraw_result(ok, "Success! Rebooting...")gfx_fill_rect(clear area, BLACK)gfx_draw_string(msg, GREEN or RED)flushesp_restart() or draw_menu() factory_visual_feedbackfactory_menu_systemfactory_update_actionsfactory_visual_feedbackfactory_menu_systemfactory_update_actionssets sd_has_fw and sd_has_resreads sd_has_fw / sd_has_res\ndraws FW / RES tags in headercalls draw_circle() + draw_check_mark()\nfrom this moduleprobe_sd_files()draw_menu()draw_header()draw_sd_indicators()draw_menu_items()draw_button_hints()

All drawing in this module ultimately calls through gfx.c. The graphics layer provides five primitives used here:

PrimitiveUsed by
gfx_hline(x, y, w, color)draw_circle, draw_check_mark, inner rendering of rect/string helpers
gfx_fill_rect(x, y, w, h, color)draw_progress (bar fill + text erase), draw_result (area clear)
gfx_rect(x, y, w, h, color)draw_progress (bar outline)
gfx_draw_string(x, y, str, fg, bg)draw_flashing_screen, draw_progress, draw_result, draw_sd_indicators
gfx_clear(color)via draw_header() in factory_menu_system

gfx_hline

gfx_fill_rect

gfx_rect

gfx_draw_string

SPI boards

I80 boards

RGB boards

factory_visual_feedback

gfx.c

(factory_graphics)

ST7796 / ILI9341

(SPI driver)

RM68120 / ST7796-I80

(I80 parallel)

ST7262 / EK9716

(RGB parallel)

The factory app does not use LVGL. All rendering is direct pixel calls through gfx.c. This avoids the LVGL heap and task dependencies, keeping the recovery app lightweight (< 100 KB code) and deterministic. See display_drivers_spi.md, display_drivers_i80.md, and display_drivers_rgb.md for driver-level details.

gfx.c accepts all colours as 16-bit RGB565 (uint16_t). gfx_hline() performs byte-swapping internally before writing to the SPI/I80 line buffer, so callers always pass native RGB565 values. Use the GFX_RGB565(r, g, b) macro defined in gfx.h:

#define GFX_RGB565(r, g, b) \
(uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3))

When ENABLE_SNAPSHOT is defined, the factory_snapshot module (triggered by the BOOT button) can capture a .raw pixel dump to the SD card at any point. Because gfx_snapshot_begin() hooks into the same gfx_flush() path that all gfx.c drawing calls use, a snapshot taken mid-flash captures an accurate copy of the current display state with zero additional rendering code.

LCDfactory_visual_feedbackSD Cardfactory_graphics (gfx.c)factory_snapshotBOOT Button ISRLCDfactory_visual_feedbackSD Cardfactory_graphics (gfx.c)factory_snapshotBOOT Button ISRwrites width + height header\npre-fills file with black pixels\nenables dual-output modere-renders complete current framecloses .raw file\ndisables dual-output modesnapshot_take()gfx_snapshot_begin("/sdcard/snap.raw")draw_flashing_screen() + draw_progress(last_percent)gfx_hline / gfx_fill_rect / …mirror every flush call to .raw file(also sent to display as normal)gfx_snapshot_end()

draw_progress is designed to always redraw the bar outline (not just the fill) so that a full-redraw triggered by snapshot_take() reproduces the complete progress bar without orphaned fill rectangles:

/* Always draw outline — required for consistent snapshot full-redraw */
gfx_rect(bar_x, bar_y, bar_w, bar_h, COLOR_WHITE);
/* Then draw proportional fill */
gfx_fill_rect(bar_x + 3, bar_y + 3, fill_w, bar_h - 6, COLOR_GREEN);

When adding a new board to the factory application:

yes - wide canvas

no - narrow canvas

Determine logical screen dimensions

(portrait-native or rotated landscape?)

Set SCREEN_WIDTH / SCREEN_HEIGHT in main.c

(logical orientation after rotation)

Choose scaling family

(see §5 table)

Set FONT_WIDTH / FONT_HEIGHT

BTN_CIRCLE_R

MENU_START_Y / MENU_ITEM_H

STATUS_Y formula

Logical width > ~500 px?

Use midpoint-based hit-test

boundary_1_2 = (CX1 + CX2) / 2

boundary_2_3 = (CX2 + CX3) / 2

Use SCREEN_WIDTH / 3 hit-test

Verify draw_check_mark

fits inside BTN_CIRCLE_R

(largest pixel span ≤ 2 × r)

Build factory app

Flash and verify all zones visually

Critical offsets to verify per board:

CheckWhat to look for
draw_progress barFully visible; not clipped by STATUS_Y or button bar
draw_result textVisible at SCREEN_HEIGHT/2 + 93; not overlapping button bar
draw_sd_indicators right edgeRight-aligned tags do not overrun screen width
draw_check_mark vertexJunction row aligns to the visual centre of BTN_CIRCLE_R
draw_circle double-ringRings at BTN_CIRCLE_R and BTN_CIRCLE_R − 1 both fully on-screen