From be69bda4f14e77b443b82504e4fd9919b632f86e Mon Sep 17 00:00:00 2001 From: patri Date: Fri, 23 Oct 2026 18:32:56 +0200 Subject: [PATCH] 12-OBD-Speed: battery voltage arc and idle/screen-off mode - ATRV every 2 s; voltage arc in the bottom gap (11-15 V) + number, colored by state (low / weak / resting / charging / overcharge) - idle after Off/2/5/10 min without speed data (settings page, NVS): screen off, GIF paused, OBD polling stopped, ATRV every 30 s; wakes on touch (swallowed by a top-layer overlay) or > 13.2 V - status pill moved up to make room for the voltage Co-Authored-By: Claude Opus 5.5 --- 12-OBD-Speed/README.md | 10 +- 12-OBD-Speed/src/main.cpp | 169 ++++++++++++++++++++++++++++++++-- 12-OBD-Speed/src/obd_link.cpp | 80 +++++++++++++++- 12-OBD-Speed/src/obd_link.h | 7 ++ CLAUDE.md | 8 +- 5 files changed, 258 insertions(+), 16 deletions(-) diff --git a/12-OBD-Speed/README.md b/12-OBD-Speed/README.md index 87927f0..76ba5b9 100644 --- a/12-OBD-Speed/README.md +++ b/12-OBD-Speed/README.md @@ -5,11 +5,17 @@ Uses the shared board library and LVGL 9.6 (`../shared`), NimBLE-Arduino 2.5.1. ## Use - **Speed page** — speed in km/h (`--` = no data: not connected, or ignition off), arc gauge 0–200 km/h, - status at the bottom (adapter name when running, otherwise what it is doing). + status pill below the number (adapter name when running, otherwise what it is doing). + **Battery voltage** (`ATRV`, every 2 s) as a small arc in the bottom gap (11–15 V) + number; color: + red < 11.8 V, orange < 12.2 V, green < 13.0 V (resting OK), blue ≤ 14.8 V (charging), red above (overcharge). - **Swipe left → adapter menu** — scans while open. OBD-like adapters (name OBD/ELM/…, or service FFF0/FFE0/18F0) are listed first in green. **Tap one** to select it: it is remembered (NVS) and connected immediately. **Forget** clears the remembered adapter, **Done** (or swipe right) returns to the speed page. -- **Swipe left again → settings** — screen rotation 0 / 90 / 180 / 270 degrees (tap to apply; remembered in NVS, applied at boot). +- **Swipe left again → settings** — screen rotation 0 / 90 / 180 / 270 degrees, and **screen off without data** Off / 2 / 5 / 10 min (default 2). Both remembered in NVS. +- **Idle** — after that time without speed data (ignition off, adapter gone) the screen goes dark, the GIF pauses and + OBD polling stops; only `ATRV` every 30 s (answered by the adapter itself, doesn't wake the car's bus). + Wakes on a touch (the touch is swallowed) or by itself when the voltage rises above 13.2 V (engine started). + Not used while no adapter is chosen. - On power-up the remembered adapter is connected automatically; if the link drops or the adapter is not there (car off), it retries every 3 s. With no adapter remembered, the device starts on the menu page. diff --git a/12-OBD-Speed/src/main.cpp b/12-OBD-Speed/src/main.cpp index 8212965..7c16486 100644 --- a/12-OBD-Speed/src/main.cpp +++ b/12-OBD-Speed/src/main.cpp @@ -1,12 +1,16 @@ // OBD speed display for the JC3636W518: vehicle speed from an ELM327 over BLE on top of an animated GIF. // Swipe left for the adapter menu (scan, select, forget); the selected adapter is remembered and reconnected. -// Swipe left again for settings (screen rotation, remembered). +// Swipe left again for settings (screen rotation, idle timeout; remembered). +// Battery voltage is shown as a small arc in the bottom gap; after a while without speed data the +// screen goes dark (idle) and wakes on touch or when the engine starts. #include #include #include "jc3636w518.h" #include "obd_link.h" #define SPEED_MAX 200 +#define VOLT_MIN_X10 110 // voltage arc range 11.0 .. 15.0 V +#define VOLT_MAX_X10 150 #define MAX_MENU_ITEMS 30 LV_FONT_DECLARE(font_speed_120); @@ -22,8 +26,10 @@ static const lv_color_t COLOR_OBD = lv_color_hex(0x4CD964); static const lv_color_t COLOR_PANEL = lv_color_hex(0x161C22); static lv_obj_t *tileview, *tile_speed, *tile_menu, *tile_settings; -static lv_obj_t *rot_buttons[4]; +static lv_obj_t *rot_buttons[4], *idle_buttons[4]; +static const uint8_t IDLE_MINUTES[4] = {0, 2, 5, 10}; // 0 = never static lv_obj_t *speed_arc, *speed_label, *speed_shadow, *status_label; +static lv_obj_t *volt_arc, *volt_label, *bg_gif, *idle_overlay; static lv_obj_t *menu_saved, *menu_status, *menu_list; static lv_obj_t *menu_items[MAX_MENU_ITEMS]; static int menu_item_count = 0; @@ -109,6 +115,38 @@ static void on_rotation_clicked(lv_event_t *e) p.end(); } +// ---------- settings: idle timeout ---------- +static uint8_t load_idle_minutes() +{ + Preferences p; + p.begin("ui", true); + uint8_t m = p.getUChar("idle", 2); + p.end(); + return m; +} + +static void highlight_idle(uint8_t minutes) +{ + for (int i = 0; i < 4; i++) + lv_obj_set_style_bg_color(idle_buttons[i], IDLE_MINUTES[i] == minutes ? COLOR_ARC : lv_color_hex(0x222A33), 0); +} + +static void on_idle_clicked(lv_event_t *e) +{ + uint8_t minutes = IDLE_MINUTES[(int)(intptr_t)lv_event_get_user_data(e)]; + obd_set_idle_timeout(minutes); + highlight_idle(minutes); + Preferences p; + p.begin("ui", false); + p.putUChar("idle", minutes); + p.end(); +} + +static void on_idle_overlay_pressed(lv_event_t *e) +{ + obd_wake(); // touch while the screen is dark: wake up (the touch itself is swallowed) +} + // ---------- speed page ---------- static void build_speed_tile() { @@ -120,10 +158,10 @@ static void build_speed_tile() gif_dsc.header.h = bg_gif_start[8] | (bg_gif_start[9] << 8); gif_dsc.data_size = bg_gif_end - bg_gif_start; gif_dsc.data = bg_gif_start; - lv_obj_t *gif = lv_gif_create(tile_speed); - lv_gif_set_color_format(gif, lv_display_get_color_format(lv_display_get_default())); - lv_gif_set_src(gif, &gif_dsc); - lv_obj_center(gif); + bg_gif = lv_gif_create(tile_speed); + lv_gif_set_color_format(bg_gif, lv_display_get_color_format(lv_display_get_default())); + lv_gif_set_src(bg_gif, &gif_dsc); + lv_obj_center(bg_gif); // arc gauge along the round edge: 270° sweep, gap at the bottom speed_arc = lv_arc_create(tile_speed); @@ -141,6 +179,28 @@ static void build_speed_tile() lv_obj_set_style_arc_opa(speed_arc, LV_OPA_30, LV_PART_MAIN); lv_obj_set_style_arc_color(speed_arc, COLOR_ARC, LV_PART_INDICATOR); + // battery voltage arc in the bottom gap of the speed arc, filling left to right + volt_arc = lv_arc_create(tile_speed); + lv_obj_set_size(volt_arc, 344, 344); + lv_obj_center(volt_arc); + lv_arc_set_rotation(volt_arc, 45); + lv_arc_set_bg_angles(volt_arc, 8, 82); // small margin to the speed arc ends + lv_arc_set_mode(volt_arc, LV_ARC_MODE_REVERSE); // starts at the left end (bottom-left) + lv_arc_set_range(volt_arc, VOLT_MIN_X10, VOLT_MAX_X10); + lv_arc_set_value(volt_arc, VOLT_MIN_X10); + lv_obj_remove_style(volt_arc, NULL, LV_PART_KNOB); + lv_obj_remove_flag(volt_arc, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_arc_width(volt_arc, 12, LV_PART_MAIN); + lv_obj_set_style_arc_width(volt_arc, 12, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(volt_arc, lv_color_hex(0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_arc_opa(volt_arc, LV_OPA_30, LV_PART_MAIN); + + volt_label = lv_label_create(tile_speed); + lv_obj_set_style_text_font(volt_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(volt_label, COLOR_DIM, 0); + lv_label_set_text(volt_label, "-- V"); + lv_obj_align(volt_label, LV_ALIGN_BOTTOM_MID, 0, -30); + // speed number with a soft drop shadow so it stays readable on the GIF speed_shadow = lv_label_create(tile_speed); lv_obj_set_style_text_font(speed_shadow, &font_speed_120, 0); @@ -171,7 +231,7 @@ static void build_speed_tile() lv_obj_set_style_pad_hor(status_label, 10, 0); lv_obj_set_style_pad_ver(status_label, 3, 0); lv_label_set_text(status_label, ""); - lv_obj_align(status_label, LV_ALIGN_BOTTOM_MID, 0, -34); + lv_obj_align(status_label, LV_ALIGN_CENTER, 0, 100); } // ---------- menu page ---------- @@ -220,14 +280,14 @@ static void build_settings_tile() lv_obj_set_style_text_font(label, &lv_font_montserrat_16, 0); lv_obj_set_style_text_color(label, COLOR_DIM, 0); lv_label_set_text(label, "Screen rotation (degrees)"); - lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 86); + lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 54); static const char *names[4] = {"0", "90", "180", "270"}; for (int i = 0; i < 4; i++) { lv_obj_t *b = lv_button_create(tile_settings); - lv_obj_set_size(b, 104, 48); - lv_obj_align(b, LV_ALIGN_TOP_MID, (i % 2) ? 58 : -58, 118 + (i / 2) * 60); + lv_obj_set_size(b, 96, 40); + lv_obj_align(b, LV_ALIGN_TOP_MID, (i % 2) ? 52 : -52, 80 + (i / 2) * 46); lv_obj_add_event_cb(b, on_rotation_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)i); lv_obj_t *l = lv_label_create(b); lv_label_set_text(l, names[i]); @@ -237,6 +297,27 @@ static void build_settings_tile() } highlight_rotation(getRotation()); + lv_obj_t *idle_label = lv_label_create(tile_settings); + lv_obj_set_style_text_font(idle_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(idle_label, COLOR_DIM, 0); + lv_label_set_text(idle_label, "Screen off without data (min)"); + lv_obj_align(idle_label, LV_ALIGN_TOP_MID, 0, 180); + + static const char *idle_names[4] = {"Off", "2", "5", "10"}; + for (int i = 0; i < 4; i++) + { + lv_obj_t *b = lv_button_create(tile_settings); + lv_obj_set_size(b, 58, 40); + lv_obj_align(b, LV_ALIGN_TOP_MID, -99 + i * 66, 206); + lv_obj_add_event_cb(b, on_idle_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)i); + lv_obj_t *l = lv_label_create(b); + lv_label_set_text(l, idle_names[i]); + lv_obj_set_style_text_font(l, &lv_font_montserrat_16, 0); + lv_obj_center(l); + idle_buttons[i] = b; + } + highlight_idle(load_idle_minutes()); + make_button(tile_settings, "Done", 0, 296, 110, on_done_clicked); } @@ -253,9 +334,74 @@ static void build_ui() build_speed_tile(); build_menu_tile(); build_settings_tile(); + + idle_overlay = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(idle_overlay); + lv_obj_set_size(idle_overlay, SCREEN_RES_HOR, SCREEN_RES_VER); + lv_obj_add_flag(idle_overlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(idle_overlay, on_idle_overlay_pressed, LV_EVENT_PRESSED, NULL); + lv_obj_add_flag(idle_overlay, LV_OBJ_FLAG_HIDDEN); } // ---------- periodic updates ---------- +static lv_color_t voltage_color(float v) +{ + if (v < 0) + return COLOR_DIM; + if (v < 11.8f) + return lv_color_hex(0xFF3B30); // red: battery low + if (v < 12.2f) + return lv_color_hex(0xFF9500); // orange: getting weak + if (v < 13.0f) + return lv_color_hex(0x4CD964); // green: resting, OK + if (v <= 14.8f) + return lv_color_hex(0x0A84FF); // blue: charging (engine running) + return lv_color_hex(0xFF3B30); // red: overcharging +} + +static void update_voltage() +{ + static int shown_x10 = -2; + float v = obd_voltage(); + int x10 = v < 0 ? -1 : (int)(v * 10 + 0.5f); + if (x10 == shown_x10) + return; + shown_x10 = x10; + lv_color_t c = voltage_color(v); + lv_arc_set_value(volt_arc, x10 < 0 ? VOLT_MIN_X10 : constrain(x10, VOLT_MIN_X10, VOLT_MAX_X10)); + lv_obj_set_style_arc_color(volt_arc, c, LV_PART_INDICATOR); + char buf[12]; + if (x10 < 0) + strcpy(buf, "-- V"); + else + snprintf(buf, sizeof(buf), "%d.%d V", x10 / 10, x10 % 10); + lv_label_set_text(volt_label, buf); + lv_obj_set_style_text_color(volt_label, c, 0); +} + +static void update_idle() +{ + static bool shown_idle = false; + bool idle = obd_is_idle(); + if (idle == shown_idle) + return; + shown_idle = idle; + if (idle) + { + // screen off, animation paused, touches only wake up + lv_tileview_set_tile(tileview, tile_speed, LV_ANIM_OFF); + lv_gif_pause(bg_gif); + lv_obj_remove_flag(idle_overlay, LV_OBJ_FLAG_HIDDEN); + screen_switch(false); + } + else + { + lv_gif_resume(bg_gif); + lv_obj_add_flag(idle_overlay, LV_OBJ_FLAG_HIDDEN); + screen_switch(true); + } +} + static void update_speed() { static int shown = -2; @@ -345,6 +491,7 @@ void setup() scr_lvgl_init(); setRotation(load_rotation()); build_ui(); + obd_set_idle_timeout(load_idle_minutes()); obd_begin(); if (obd_saved_address().length() == 0) { @@ -363,6 +510,8 @@ void loop() { last_speed = now; update_speed(); + update_voltage(); + update_idle(); } if (now - last_menu >= 500 && menu_visible()) { diff --git a/12-OBD-Speed/src/obd_link.cpp b/12-OBD-Speed/src/obd_link.cpp index 6b0e29f..502be54 100644 --- a/12-OBD-Speed/src/obd_link.cpp +++ b/12-OBD-Speed/src/obd_link.cpp @@ -4,12 +4,20 @@ #include #define RETRY_DELAY_MS 3000 +#define IDLE_RETRY_DELAY_MS 30000 #define POLL_FAIL_LIMIT 3 +#define VOLTAGE_INTERVAL_MS 2000 // ATRV while driving +#define IDLE_VOLTAGE_INTERVAL_MS 30000 // ATRV while idle (adapter-internal, doesn't wake the car's bus) +#define WAKE_VOLTAGE 13.2f // above this the alternator is charging: engine running // ---------- shared state (protected by `mtx`) ---------- static SemaphoreHandle_t mtx; static volatile ObdState state = ObdState::NoDevice; static volatile int speed_kmh = -1; +static volatile float voltage = -1; +static volatile bool idle = false; +static volatile uint32_t idle_timeout_ms = 2 * 60 * 1000; +static volatile uint32_t last_data_ms = 0; // last valid speed (or wake-up) static String status_text = "starting"; static String saved_addr, saved_name; static uint8_t saved_type = 0; @@ -18,7 +26,7 @@ static uint32_t devices_version = 1; static String elm_rx; // bytes received from the ELM (notifications) // ---------- requests from the UI ---------- -static volatile bool req_select = false, req_forget = false; +static volatile bool req_select = false, req_forget = false, req_wake = false; static volatile bool scan_wanted = false; static ObdDevInfo req_dev; @@ -155,6 +163,23 @@ static int parse_speed(String reply) return (int)strtol(reply.substring(i + 4, i + 6).c_str(), nullptr, 16); } +// "12.6V" -> 12.6, -1 if not a voltage +static float parse_voltage(const String &reply) +{ + int v = reply.indexOf('V'); + if (v <= 0) + return -1; + float f = reply.substring(0, v).toFloat(); + return f > 0 ? f : -1; +} + +static void read_voltage() +{ + float v = parse_voltage(elm_command("ATRV", 1500)); + if (v > 0) + voltage = v; +} + // ---------- connection ---------- static void disconnect() { @@ -259,9 +284,28 @@ static void load_saved() static void worker_task(void *) { uint32_t next_attempt = 0; + uint32_t next_voltage = 0; int fails = 0; + last_data_ms = millis(); for (;;) { + if (req_wake) + { + req_wake = false; + if (idle) + Serial.println("[obd] wake (touch)"); + idle = false; + last_data_ms = millis(); + next_attempt = 0; + } + // no speed data for a while (ignition off / adapter gone): go idle + if (!idle && idle_timeout_ms && millis() - last_data_ms > idle_timeout_ms) + { + idle = true; + speed_kmh = -1; + Serial.println("[obd] idle"); + } + // requests from the UI if (req_forget) { @@ -296,6 +340,8 @@ static void worker_task(void *) if (saved_addr.length() == 0) { state = ObdState::NoDevice; + idle = false; + last_data_ms = millis(); // no idle while no adapter is chosen (user is setting up) set_status("No adapter - swipe left"); vTaskDelay(pdMS_TO_TICKS(100)); continue; @@ -317,11 +363,36 @@ static void worker_task(void *) { disconnect(); state = ObdState::Waiting; - next_attempt = millis() + RETRY_DELAY_MS; + next_attempt = millis() + (idle ? IDLE_RETRY_DELAY_MS : RETRY_DELAY_MS); } + next_voltage = 0; continue; } + if (idle) + { + // only the battery voltage now and then; an engine start (charging voltage) wakes us up + if ((int32_t)(millis() - next_voltage) >= 0) + { + next_voltage = millis() + IDLE_VOLTAGE_INTERVAL_MS; + read_voltage(); + if (voltage > WAKE_VOLTAGE) + { + idle = false; + last_data_ms = millis(); + Serial.printf("[obd] wake (%.1f V)\n", voltage); + } + } + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + + if ((int32_t)(millis() - next_voltage) >= 0) + { + next_voltage = millis() + VOLTAGE_INTERVAL_MS; + read_voltage(); + } + // connected: poll the vehicle speed String reply = elm_command("010D", 1500); int v = parse_speed(reply); @@ -329,6 +400,7 @@ static void worker_task(void *) if (v >= 0) { fails = 0; + last_data_ms = millis(); set_status(saved_name.length() ? saved_name : saved_addr); } else if (reply.length() == 0) @@ -365,6 +437,10 @@ void obd_begin() ObdState obd_state() { return state; } int obd_speed() { return speed_kmh; } +float obd_voltage() { return voltage; } +bool obd_is_idle() { return idle; } +void obd_wake() { req_wake = true; } +void obd_set_idle_timeout(uint32_t minutes) { idle_timeout_ms = minutes * 60 * 1000; } String obd_status_text() { diff --git a/12-OBD-Speed/src/obd_link.h b/12-OBD-Speed/src/obd_link.h index 03f1915..4319e24 100644 --- a/12-OBD-Speed/src/obd_link.h +++ b/12-OBD-Speed/src/obd_link.h @@ -28,6 +28,13 @@ void obd_begin(); ObdState obd_state(); int obd_speed(); // km/h, -1 = no data (ignition off / not connected) String obd_status_text(); // short human-readable status line +float obd_voltage(); // battery voltage from ATRV (V), -1 = unknown + +// Idle: after `minutes` without speed data (0 = never) polling stops; only ATRV every 30 s. +// Leaves idle by itself when the voltage shows the engine running (> 13.2 V), or via obd_wake(). +void obd_set_idle_timeout(uint32_t minutes); +bool obd_is_idle(); +void obd_wake(); String obd_saved_name(); // "" if none String obd_saved_address(); diff --git a/CLAUDE.md b/CLAUDE.md index 54104d8..fc72f31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Display color format is `RGB565_SWAPPED` (ST77916 byte order, set in `lv_conf.h` | `1-Instructions` | Spec PDF (duplicate), AIDA64 remote-sensor layout `aida_remote_1.85.rslcd`, MJPEG conversion tool (Windows .NET, converts video to 360×360 MJPEG 25 fps) | | `2-Program Example/Demo_Arduino` | **Vendor only:** `library/` (3 libs), `lv_conf.h` (vendor original), `sd/` (SD listing example; its lv_conf.h has FATFS edits), `docs/` (setup screenshots + compile instructions). The vendor's original LVGL demo sketch no longer exists unmodified. | | `shared` | **Shared code for all JC3636W518 projects:** `jc3636w518.ini` (PlatformIO base config), `include/lv_conf.h` (LVGL 9.6), `lib/JC3636W518` (board library). See its `README.md` | -| `12-OBD-Speed` | **The new program — working in the car (2026-10-23):** speed from the ELM327 over BLE as big number + arc gauge on a GIF background; swipe left for the adapter menu (scan, select → remembered in NVS, auto-reconnect every 3 s). See its `README.md` | +| `12-OBD-Speed` | **The new program — working in the car (2026-10-23):** speed from the ELM327 over BLE as big number + arc gauge on a GIF background, battery-voltage arc in the bottom gap; swipe left for the adapter menu (scan, select → remembered in NVS, auto-reconnect every 3 s), again for settings (rotation, idle/screen-off timeout). See its `README.md` | | `10-GIF-Tests` | **GIF playback experiments** (was `10-OBD-Display-2024`): `display_gif_embedded`, `display_gif_sd`, `assets/gifs`. The 2024 bridge/ELMulator/WiFi speed sketch, the broken LVGL 9 archive and the BLE scanner tool (`11-BLE-Scanner`: on-screen scan, GATT listing, ELM test, LittleFS log) were removed 2026-10-23 — all in git history | | `3-Specification` | Spec sheet (factory firmware features, interface photo) | | `4-Chip manual` | ESP32-S3R8 datasheet, PCM5100A datasheet | @@ -93,6 +93,9 @@ Display color format is `RGB565_SWAPPED` (ST77916 byte order, set in `lv_conf.h` AIDA64 secondary screen (18 styles), mic spectrum, MP3 player (`/music` on SD), photo frame (`/pic`, 360×360 JPEG), MJPEG player (`/mjpeg`), weather, theme clocks, WiFi provisioning AP "My App"/12345678 + web config at device IP. Re-flash it with Flash Download Tool: ESP32-S3, `1.85_demo.bin` @ 0x0. +## Car battery / idle +Cheap ELM327 clones draw ~20–50 mA permanently (OBD pin 16 = always 12 V); the display ~180 mA if its USB stays powered. `12-OBD-Speed` idle mode stops OBD polling after N min without data so the car's ECUs can sleep; `ATRV` (adapter-internal) every 30 s detects an engine start (> 13.2 V). + ## OBD adapter (user's) AliExpress "ELM327 Mini V2.1 Bluetooth OBD **Dual Mode** 5.1" — dual mode = Bluetooth Classic SPP **and BLE**, so the ESP32-S3 should be able to connect directly over BLE (no bridge ESP32). **Verified in the car (2026-10-23, with the since-removed BLE scanner tool; log in `12-OBD-Speed/docs/2026-10-23_ble_scanner_car_test.txt`):** @@ -123,5 +126,6 @@ AliExpress "ELM327 Mini V2.1 Bluetooth OBD **Dual Mode** 5.1" — dual mode = Bl - BLE (NimBLE) in the user's home: ~12 devices visible within 10 s. - Big fonts: generate with `npx lv_font_conv` (Node 22 installed) from `lvgl/scripts/generators/built_in_font/Montserrat-Medium.ttf`; `-DLV_LVGL_H_INCLUDE_SIMPLE` (in shared ini) makes the generated `#include` work. - Binary assets (GIFs): `board_build.embed_files = data/x.gif` → `extern const uint8_t x_start[] asm("_binary_data_x_gif_start")` (no huge C arrays). -- Serial output right after a reset can be lost/garbled (USB-CDC re-enumeration, prints from two cores interleave) — don't conclude a task isn't running from a missing early line. +- Python edit scripts: never pass C strings containing `\n` through a bash heredoc into Python — it turned into a real line break twice; write the script with the Write tool instead. +- Serial output from the background task (core 0) is often lost, not only right after reset; don't rely on it as proof. Serial output right after a reset can be lost/garbled (USB-CDC re-enumeration, prints from two cores interleave) — don't conclude a task isn't running from a missing early line. - `Preferences` prints `nvs_open failed: NOT_FOUND` the first time a namespace is read before it was written — harmless.