Add serial-triggered performance profile test

Debug command 'P' runs a standardized 5-test benchmark:
1. Idle frame (nothing dirty) — measures LVGL overhead
2. Full frame invalidation + render — measures render + flush
3. Data update cycle — measures label formatting cost
4. Navigate all 5 tiles — measures tile transition cost
5. 10-frame burst — measures sustained frame rate

Results reported as JSON with build timestamp for regression
tracking. Python tool: ./scripts/screenshot.py profile

Baseline (Mar 29 2026, hwcdc, unprovisioned):
  Idle: 914µs, Full: 80ms (37ms flush + 42ms render),
  Data update: 2ms, Per-tile: 75ms, Per-frame: 80ms,
  Main loop: 331µs idle
This commit is contained in:
GlassOnTin 2026-03-29 10:58:50 +01:00
commit 5a1f8eb8f4
2 changed files with 129 additions and 1 deletions

96
Gui.h
View file

@ -798,7 +798,8 @@ bool gui_init() {
// 'M' (0x4D) — Metrics: responds RWSM + JSON stats
// 'I' (0x49) — Invalidate: force full screen redraw
// 'L' (0x4C) — Log toggle: start/stop IMU logging to SD card
// 'F' (0x46) — File download: 1 byte name length + filename, sends file over serial
// 'F' (0x46) — File list: lists files on SD card
// 'P' (0x50) — Profile: runs standardized performance test, reports JSON results
#define GUI_CMD_PREFIX_LEN 3
static const uint8_t gui_cmd_prefix[] = {0x52, 0x57, 0x53}; // "RWS"
@ -929,6 +930,99 @@ static void gui_cmd_execute() {
break;
}
case 'P': { // Standardized performance profile test
Serial.write(hdr, 4);
if (display_blanked) display_unblank();
uint32_t p_t0, p_t1;
uint32_t p_idle_render = 0, p_idle_flush = 0;
uint32_t p_full_render = 0, p_full_flush = 0;
uint32_t p_nav_total = 0;
uint32_t p_data_update = 0;
uint32_t p_frames = 0;
uint32_t p_multi_total = 0;
// Test 1: Idle frame (nothing dirty)
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler(); // clear any pending
gui_flush_us_last = 0;
gui_frame_count = 0;
p_t0 = micros();
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
p_t1 = micros();
p_idle_render = p_t1 - p_t0;
p_idle_flush = gui_flush_us_last;
// Test 2: Full invalidation + render
lv_obj_invalidate(lv_screen_active());
gui_flush_us_last = 0;
p_t0 = micros();
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
p_t1 = micros();
p_full_render = p_t1 - p_t0;
p_full_flush = gui_flush_us_last;
// Test 3: Data update cycle
gui_last_data_update = 0; // force update
p_t0 = micros();
gui_update_data();
p_t1 = micros();
p_data_update = p_t1 - p_t0;
// Test 4: Navigate to each tile and back (5 transitions)
p_t0 = micros();
lv_tileview_set_tile(gui_tileview, gui_tile_radio, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
lv_tileview_set_tile(gui_tileview, gui_tile_gps, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
lv_tileview_set_tile(gui_tileview, gui_tile_msg, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
lv_tileview_set_tile(gui_tileview, gui_tile_set, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
lv_tileview_set_tile(gui_tileview, gui_tile_watch, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
p_t1 = micros();
p_nav_total = p_t1 - p_t0;
// Test 5: Rapid frame burst (10 full frames)
p_t0 = micros();
for (int i = 0; i < 10; i++) {
lv_obj_invalidate(lv_screen_active());
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
}
p_t1 = micros();
p_multi_total = p_t1 - p_t0;
Serial.printf("{\"test\":\"profile\",\"build\":\"%s %s\","
"\"idle_us\":%lu,\"idle_flush_us\":%lu,"
"\"full_us\":%lu,\"full_flush_us\":%lu,"
"\"data_update_us\":%lu,"
"\"nav_5tile_us\":%lu,"
"\"burst_10frame_us\":%lu,"
"\"avg_frame_us\":%lu,"
"\"loop_us\":%lu,"
"\"heap\":%lu,\"psram\":%lu}\n",
__DATE__, __TIME__,
p_idle_render, p_idle_flush,
p_full_render, p_full_flush,
p_data_update,
p_nav_total,
p_multi_total, p_multi_total / 10,
gui_loop_us_last,
(uint32_t)esp_get_free_heap_size(),
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
Serial.flush();
break;
}
case 'I': { // Invalidate — force full redraw
if (gui_screen) lv_obj_invalidate(gui_screen);
if (display_blanked) display_unblank();

View file

@ -153,6 +153,35 @@ def cmd_navigate(s, screen):
print(f"Navigate → {screen} ({col},{row})")
def cmd_profile(s):
"""Run standardized performance profile test"""
send_cmd(s, ord('P'))
buf = b""
deadline = time.time() + 30 # profile test takes several seconds
while time.time() < deadline:
chunk = s.read(max(1, s.in_waiting or 1))
if chunk:
buf += chunk
magic = PREFIX + b"P"
idx = buf.find(magic)
if idx >= 0:
nl = buf.find(b"\n", idx + 4)
if nl >= 0:
import json
data = json.loads(buf[idx + 4:nl])
print(f"Build: {data.get('build', '?')}")
print(f"Idle frame: {data['idle_us']:>8} µs (flush: {data['idle_flush_us']} µs)")
print(f"Full frame: {data['full_us']:>8} µs (flush: {data['full_flush_us']} µs)")
print(f"Data update: {data['data_update_us']:>8} µs")
print(f"Nav 5 tiles: {data['nav_5tile_us']:>8} µs ({data['nav_5tile_us']//5} µs/tile)")
print(f"Burst 10 frame: {data['burst_10frame_us']:>8} µs ({data['avg_frame_us']} µs/frame)")
print(f"Main loop: {data['loop_us']:>8} µs")
print(f"Heap free: {data['heap']:>8} bytes")
print(f"PSRAM free: {data['psram']:>8} bytes")
return
print(f"Timeout ({len(buf)} bytes)")
def cmd_invalidate(s):
send_cmd(s, ord('I'))
print("Invalidated — full redraw requested")
@ -217,6 +246,9 @@ def main():
sub.add_parser("invalidate", aliases=["inv"])
sub.add_parser("profile", aliases=["p"],
help="Run standardized performance test")
sub.add_parser("log", aliases=["l"],
help="Toggle IMU logging to SD card")
@ -242,6 +274,8 @@ def main():
cmd_navigate(s, args.screen)
elif args.command in ("invalidate", "inv"):
cmd_invalidate(s)
elif args.command in ("profile", "p"):
cmd_profile(s)
elif args.command in ("log", "l"):
cmd_log(s)
elif args.command in ("files", "f"):