From c9fb70ad1075f26ff6e68f233f3e091a7eafa77a Mon Sep 17 00:00:00 2001 From: patri Date: Fri, 23 Oct 2026 18:58:44 +0200 Subject: [PATCH] 12-OBD-Speed: choose the background GIF from the SD card - background page (4th tile): built-in or any GIF in /gifs on the SD card - chosen GIF is copied into a raw 11.9 MB 'bggif' flash partition (header written last) and played memory-mapped from flash; SD only needed when choosing or when the file changed (checked at boot in the background) - own partitions.csv: 4 MB app + bggif + NVS - tools/gif_to_360.py: convert GIFs to 360x360 keeping frame timing; converted output (12-OBD-Speed/sd/) is git-ignored Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + 12-OBD-Speed/README.md | 19 +- 12-OBD-Speed/partitions.csv | 6 + 12-OBD-Speed/platformio.ini | 4 +- 12-OBD-Speed/src/bg_store.cpp | 353 ++++++++++++++++++++++++++++++++++ 12-OBD-Speed/src/bg_store.h | 38 ++++ 12-OBD-Speed/src/main.cpp | 171 ++++++++++++++-- CLAUDE.md | 3 +- tools/gif_to_360.py | 71 +++++++ 9 files changed, 651 insertions(+), 17 deletions(-) create mode 100644 12-OBD-Speed/partitions.csv create mode 100644 12-OBD-Speed/src/bg_store.cpp create mode 100644 12-OBD-Speed/src/bg_store.h create mode 100644 tools/gif_to_360.py diff --git a/.gitignore b/.gitignore index 37e738a..f32478c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ .vscode/c_cpp_properties.json .vscode/launch.json .vscode/ipch + +# generated by tools/gif_to_360.py (copy to the SD card, regenerate any time) +12-OBD-Speed/sd/ diff --git a/12-OBD-Speed/README.md b/12-OBD-Speed/README.md index 76ba5b9..fefa295 100644 --- a/12-OBD-Speed/README.md +++ b/12-OBD-Speed/README.md @@ -12,6 +12,10 @@ Uses the shared board library and LVGL 9.6 (`../shared`), NimBLE-Arduino 2.5.1. 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, and **screen off without data** Off / 2 / 5 / 10 min (default 2). Both remembered in NVS. +- **Swipe left once more → background** — `Built-in` plus every GIF in `/gifs` on the SD card (size and + dimensions shown; not 360×360 in orange). Tap one: it is copied into the device's flash (progress shown) and + played from there, so the SD card is not needed afterwards. At boot the device checks (in the background) + whether the chosen file on the SD card changed (size/date) and re-copies it. **Rescan** re-reads the card. - **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). @@ -31,6 +35,17 @@ Serial (115200) prints status changes as `[obd] ...`. | `src/main.cpp` | LVGL UI (tileview: speed page + menu page) | | `src/obd_link.cpp/.h` | BLE scan, connect/reconnect, ELM327 init and speed polling (background task on core 0) | | `src/font_speed_120.c` | Montserrat Medium 120 px, digits and `-`, 4 bpp (generated with `lv_font_conv`, see header) | -| `data/bg.gif` | Background (`ezgif-5-cb56986bc7.gif`, 360×360), linked into the firmware via `board_build.embed_files` | +| `src/bg_store.cpp/.h` | Background GIF from the SD card: list `/gifs`, copy into the `bggif` flash partition (header written last), mmap for playback, change check at boot (background task) | +| `partitions.csv` | 4 MB app (no OTA) + 11.9 MB `bggif` raw area + NVS | +| `data/bg.gif` | Built-in background (`ezgif-5-cb56986bc7.gif`, 360×360), linked into the firmware via `board_build.embed_files` | +| `sd/gifs/` | (not in git) GIFs converted with `../tools/gif_to_360.py`, ready to copy to the SD card | -Replace `data/bg.gif` with any 360×360 GIF to change the background (keep it under ~3 MB). +## GIFs for the SD card +Convert any GIF to 360×360 (scale to cover, center crop, frame timing kept) with +``` +pip install pillow +python ../tools/gif_to_360.py -o sd/gifs +``` +and copy the files into a folder `gifs` on the SD card. Max ~11.5 MB per GIF. GIFs are played from flash at their own +frame rate (360×360: ~10–12 ms decode per frame). +Replace `data/bg.gif` to change the built-in background. diff --git a/12-OBD-Speed/partitions.csv b/12-OBD-Speed/partitions.csv new file mode 100644 index 0000000..4e9f4e0 --- /dev/null +++ b/12-OBD-Speed/partitions.csv @@ -0,0 +1,6 @@ +# JC3636W518 OBD speed: one 4 MB app (no OTA) + 11.9 MB raw area for the background GIF (written/mmapped by bg_store.cpp) +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000, +app0, app, factory, 0x10000, 0x400000, +bggif, data, 0x40, 0x410000, 0xBE0000, +coredump, data, coredump, 0xFF0000, 0x10000, diff --git a/12-OBD-Speed/platformio.ini b/12-OBD-Speed/platformio.ini index 91aaf5c..98bc723 100644 --- a/12-OBD-Speed/platformio.ini +++ b/12-OBD-Speed/platformio.ini @@ -9,8 +9,8 @@ dir = ../shared [env:jc3636w518] extends = jc3636w518 -; firmware + 1.6 MB background GIF does not fit huge_app (3 MB): 6.25 MB app slots -board_build.partitions = default_16MB.csv +; 4 MB app (no OTA) + 11.9 MB raw 'bggif' area for the background GIF chosen from the SD card +board_build.partitions = partitions.csv ; background GIF linked into the firmware as-is (symbols _binary_data_bg_gif_start/_end) board_build.embed_files = data/bg.gif lib_deps = diff --git a/12-OBD-Speed/src/bg_store.cpp b/12-OBD-Speed/src/bg_store.cpp new file mode 100644 index 0000000..f4c1fb1 --- /dev/null +++ b/12-OBD-Speed/src/bg_store.cpp @@ -0,0 +1,353 @@ +#include "bg_store.h" + +#include +#include +#include +#include + +#include "pincfg.h" + +#define BG_MAGIC 0x46494742 // "BGIF" +#define BG_DATA_OFFSET 4096 // header in the first sector, GIF data after it +#define COPY_CHUNK (32 * 1024) + +struct BgHeader +{ + uint32_t magic; + uint32_t size; + uint32_t mtime; + char name[64]; +}; + +// ---------- shared state (protected by `mtx`) ---------- +static SemaphoreHandle_t mtx; +static String selected; // "" = built-in +static String status = ""; +static std::vector files; +static uint32_t files_version = 1; +static volatile uint32_t source_version = 1; +static volatile bool busy = false; + +// stored GIF (worker writes, UI reads after source_version changes) +static const esp_partition_t *part = nullptr; +static esp_partition_mmap_handle_t map_handle; +static const uint8_t *map_ptr = nullptr; +static volatile bool map_valid = false; +static BgHeader header; + +// requests / handshake +static volatile bool req_list = false, req_select = false, req_boot_check = false; +static String req_name; +static volatile bool release_req = false, release_ack = false; + +static void lock() { xSemaphoreTake(mtx, portMAX_DELAY); } +static void unlock() { xSemaphoreGive(mtx); } + +static void set_status(const String &s) +{ + lock(); + status = s; + unlock(); + Serial.println("[bg] " + s); +} + +// ---------- flash partition ---------- +static void map_partition() +{ + map_valid = false; + map_ptr = nullptr; + if (!part) + return; + const void *ptr; + if (esp_partition_mmap(part, 0, part->size, ESP_PARTITION_MMAP_DATA, &ptr, &map_handle) != ESP_OK) + { + Serial.println("[bg] mmap failed"); + return; + } + map_ptr = (const uint8_t *)ptr; + memcpy(&header, map_ptr, sizeof(header)); + header.name[sizeof(header.name) - 1] = 0; + map_valid = header.magic == BG_MAGIC && header.size > 0 && header.size <= part->size - BG_DATA_OFFSET; +} + +static void unmap_partition() +{ + if (map_ptr) + esp_partition_munmap(map_handle); + map_ptr = nullptr; + map_valid = false; +} + +// ---------- SD card ---------- +static bool sd_mount() +{ + SD_MMC.setPins(SD_MMC_CLK_PIN, SD_MMC_CMD_PIN, SD_MMC_D0_PIN, SD_MMC_D1_PIN, SD_MMC_D2_PIN, SD_MMC_D3_PIN); + return SD_MMC.begin() && SD_MMC.cardType() != CARD_NONE; +} + +static void sd_unmount() { SD_MMC.end(); } + +static void do_list() +{ + set_status("Reading SD card..."); + if (!sd_mount()) + { + set_status("SD card not found"); + return; + } + std::vector found; + File dir = SD_MMC.open(BG_SD_FOLDER); + if (dir && dir.isDirectory()) + { + for (File f = dir.openNextFile(); f; f = dir.openNextFile()) + { + String n = f.name(); + String lower = n; + lower.toLowerCase(); + if (f.isDirectory() || !lower.endsWith(".gif") || n.startsWith(".")) + continue; + BgFile b; + b.name = n; + b.size = f.size(); + uint8_t hdr[10] = {0}; + f.read(hdr, sizeof(hdr)); + b.w = hdr[6] | (hdr[7] << 8); + b.h = hdr[8] | (hdr[9] << 8); + found.push_back(b); + } + } + sd_unmount(); + lock(); + files = found; + files_version++; + unlock(); + set_status(dir ? String(found.size()) + " GIFs on the SD card" : String("No " BG_SD_FOLDER " folder on the SD card")); +} + +// Copies /gifs/ into the flash partition (SD must be mounted). Returns true on success. +static bool copy_to_flash(const String &name, File &f) +{ + uint32_t size = f.size(); + if (size + BG_DATA_OFFSET > part->size) + { + set_status("Too large: " + String(size / 1048576.0f, 1) + " MB"); + return false; + } + + // the UI is playing the stored GIF straight from this flash: make it switch to the built-in one first + release_ack = false; + release_req = true; + while (!release_ack) + vTaskDelay(pdMS_TO_TICKS(10)); + release_req = false; + unmap_partition(); + + set_status("Erasing flash..."); + uint32_t erase_size = (BG_DATA_OFFSET + size + 0xFFFF) & ~0xFFFF; // whole 64 KB blocks (fast erase) + if (erase_size > part->size) + erase_size = part->size; + if (esp_partition_erase_range(part, 0, erase_size) != ESP_OK) + { + set_status("Flash erase failed"); + map_partition(); + return false; + } + + uint8_t *buf = (uint8_t *)heap_caps_malloc(COPY_CHUNK, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + uint32_t done = 0; + bool ok = buf != nullptr; + int last_pct = -1; + while (ok && done < size) + { + size_t n = f.read(buf, min((uint32_t)COPY_CHUNK, size - done)); + if (n == 0 || esp_partition_write(part, BG_DATA_OFFSET + done, buf, n) != ESP_OK) + ok = false; + done += n; + int pct = done * 100 / size; + if (pct / 5 != last_pct / 5) + { + last_pct = pct; + set_status("Copying " + String(pct) + " %"); + } + } + free(buf); + + if (ok) + { + // header last: an interrupted copy never looks valid + BgHeader h = {}; + h.magic = BG_MAGIC; + h.size = size; + h.mtime = (uint32_t)f.getLastWrite(); + strncpy(h.name, name.c_str(), sizeof(h.name) - 1); + ok = esp_partition_write(part, 0, &h, sizeof(h)) == ESP_OK; + } + map_partition(); + set_status(ok ? "Stored: " + name : String("Copy failed")); + return ok && map_valid; +} + +// Makes sure the flash holds the current version of /gifs/. Returns false if it could not. +static bool sync_file(const String &name, bool quiet) +{ + if (!quiet) + set_status("Reading SD card..."); + if (!sd_mount()) + { + set_status(map_valid && name == header.name ? "SD card not found, using stored copy" : "SD card not found"); + return map_valid && name == header.name; + } + bool ok; + File f = SD_MMC.open(String(BG_SD_FOLDER) + "/" + name); + if (!f) + { + set_status(map_valid && name == header.name ? "File gone from SD, using stored copy" : "File not found"); + ok = map_valid && name == header.name; + } + else if (map_valid && name == header.name && f.size() == header.size && (uint32_t)f.getLastWrite() == header.mtime) + { + if (!quiet) + set_status("Up to date: " + name); + ok = true; + } + else + { + ok = copy_to_flash(name, f); + } + if (f) + f.close(); + sd_unmount(); + return ok; +} + +static void save_selected(const String &name) +{ + Preferences p; + p.begin("ui", false); + p.putString("bg", name); + p.end(); + lock(); + selected = name; + unlock(); + source_version++; +} + +static void worker_task(void *) +{ + for (;;) + { + if (req_boot_check) + { + req_boot_check = false; + busy = true; + String name = bg_selected(); + if (name.length()) + { + uint32_t before = header.size ^ header.mtime; + sync_file(name, true); + if ((header.size ^ header.mtime) != before) + source_version++; + } + busy = false; + } + if (req_select) + { + req_select = false; + busy = true; + String name = req_name; + if (name.length() == 0 || sync_file(name, false)) + { + save_selected(name); + if (name.length() == 0) + set_status("Built-in background"); + } + busy = false; + } + if (req_list) + { + req_list = false; + busy = true; + do_list(); + busy = false; + } + vTaskDelay(pdMS_TO_TICKS(50)); + } +} + +// ---------- public API ---------- +void bg_begin() +{ + mtx = xSemaphoreCreateMutex(); + Preferences p; + p.begin("ui", true); + selected = p.getString("bg", ""); + p.end(); + part = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, (esp_partition_subtype_t)0x40, "bggif"); + if (!part) + Serial.println("[bg] partition 'bggif' not found"); + map_partition(); + Serial.printf("[bg] selected '%s', stored '%s' (%s)\n", selected.c_str(), map_valid ? header.name : "", + map_valid ? "valid" : "none"); + req_boot_check = true; // look for an updated version on the SD card in the background + xTaskCreatePinnedToCore(worker_task, "bg_store", 6144, nullptr, 1, nullptr, 0); +} + +String bg_selected() +{ + lock(); + String s = selected; + unlock(); + return s; +} + +const uint8_t *bg_stored_data(uint32_t *size) +{ + if (!map_valid) + return nullptr; + *size = header.size; + return map_ptr + BG_DATA_OFFSET; +} + +String bg_stored_name() { return map_valid ? String(header.name) : String(); } + +void bg_request_list() { req_list = true; } + +bool bg_get_list(std::vector &out, uint32_t &version) +{ + lock(); + bool changed = version != files_version; + if (changed) + { + out = files; + version = files_version; + } + unlock(); + return changed; +} + +void bg_request_select(const String &name) +{ + req_name = name; + req_select = true; +} + +String bg_status() +{ + lock(); + String s = status; + unlock(); + return s; +} + +bool bg_busy() { return busy || req_select || req_list; } + +bool bg_release_requested() { return release_req && !release_ack; } +void bg_release_done() { release_ack = true; } + +bool bg_source_changed(uint32_t &version) +{ + if (version == source_version) + return false; + version = source_version; + return true; +} diff --git a/12-OBD-Speed/src/bg_store.h b/12-OBD-Speed/src/bg_store.h new file mode 100644 index 0000000..e44c1d3 --- /dev/null +++ b/12-OBD-Speed/src/bg_store.h @@ -0,0 +1,38 @@ +// Background GIF storage: GIFs from the SD card (/gifs) are copied into a raw flash partition ("bggif") +// and played straight from flash (memory-mapped), so the SD card is only needed when choosing / updating. +// SD and flash work runs in a background task; all functions are safe to call from the UI (loop) task. +#pragma once + +#include +#include + +#define BG_SD_FOLDER "/gifs" + +struct BgFile +{ + String name; // file name in /gifs + uint32_t size; // bytes + uint16_t w, h; // GIF logical screen size +}; + +void bg_begin(); // maps the stored GIF and checks the SD card for an updated version + +// Currently selected background: "" = built-in, otherwise a file name from /gifs +String bg_selected(); + +// Stored GIF in flash, nullptr if none. Only valid for bg_selected() when bg_stored_name() matches. +const uint8_t *bg_stored_data(uint32_t *size); +String bg_stored_name(); + +// Menu +void bg_request_list(); // mount SD and list /gifs +bool bg_get_list(std::vector &out, uint32_t &version); +void bg_request_select(const String &name); // "" = built-in; copies to flash if needed +String bg_status(); // "Copying 45 %", "SD card not found", ... +bool bg_busy(); + +// UI handshake: the UI must stop using the stored GIF before it is overwritten, +// and reload the background when the source changed. +bool bg_release_requested(); +void bg_release_done(); +bool bg_source_changed(uint32_t &version); diff --git a/12-OBD-Speed/src/main.cpp b/12-OBD-Speed/src/main.cpp index 7c16486..13c994d 100644 --- a/12-OBD-Speed/src/main.cpp +++ b/12-OBD-Speed/src/main.cpp @@ -1,17 +1,20 @@ // 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, idle timeout; remembered). +// Swipe left again for settings (screen rotation, idle timeout; remembered), and once more for the +// background page (built-in GIF or one from /gifs on the SD card, copied into flash). // 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" +#include "bg_store.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 +#define MAX_BG_ITEMS 40 LV_FONT_DECLARE(font_speed_120); @@ -25,7 +28,12 @@ static const lv_color_t COLOR_ARC = lv_color_hex(0x00C8FF); 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 *tileview, *tile_speed, *tile_menu, *tile_settings, *tile_bg; +static lv_obj_t *bg_status_label, *bg_list; +static std::vector bg_files; +static uint32_t bg_list_version = 0, bg_source_version = 0; +static String bg_list_key; // files + selection currently shown in the list +static lv_image_dsc_t builtin_dsc, stored_dsc; 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; @@ -67,6 +75,8 @@ static bool menu_visible() static void on_tile_changed(lv_event_t *e) { obd_scan_enable(menu_visible()); // scan only while the menu is open + if (lv_tileview_get_tile_active(tileview) == tile_bg) + bg_request_list(); } static void on_device_clicked(lv_event_t *e) @@ -147,20 +157,67 @@ 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) } +// ---------- background ---------- +static void fill_gif_dsc(lv_image_dsc_t *dsc, const uint8_t *data, uint32_t size) +{ + dsc->header.magic = LV_IMAGE_HEADER_MAGIC; + dsc->header.cf = LV_COLOR_FORMAT_RAW; + dsc->header.w = data[6] | (data[7] << 8); // GIF logical screen size + dsc->header.h = data[8] | (data[9] << 8); + dsc->data_size = size; + dsc->data = data; +} + +static void set_gif_source(const lv_image_dsc_t *dsc) +{ + lv_gif_set_src(bg_gif, dsc); + if (obd_is_idle()) + lv_gif_pause(bg_gif); // keep the animation stopped while the screen is dark +} + +// Selected GIF from flash if it is stored there, otherwise the built-in one +static void apply_background() +{ + String sel = bg_selected(); + uint32_t size; + const uint8_t *data = bg_stored_data(&size); + if (sel.length() && data && bg_stored_name() == sel) + { + fill_gif_dsc(&stored_dsc, data, size); + set_gif_source(&stored_dsc); + } + else + { + set_gif_source(&builtin_dsc); + } +} + +static void on_bg_item_clicked(lv_event_t *e) +{ + if (bg_busy()) + return; + int idx = (int)(intptr_t)lv_event_get_user_data(e); // -1 = built-in + if (idx < 0) + bg_request_select(""); + else if (idx < (int)bg_files.size()) + bg_request_select(bg_files[idx].name); +} + +static void on_bg_rescan_clicked(lv_event_t *e) +{ + if (!bg_busy()) + bg_request_list(); +} + // ---------- speed page ---------- static void build_speed_tile() { - // background GIF, decoded straight into the display format (no transparency needed on black) - static lv_image_dsc_t gif_dsc; - gif_dsc.header.magic = LV_IMAGE_HEADER_MAGIC; - gif_dsc.header.cf = LV_COLOR_FORMAT_RAW; - gif_dsc.header.w = bg_gif_start[6] | (bg_gif_start[7] << 8); - 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; + // background GIF (built-in until apply_background() picks the stored one), + // decoded straight into the display format (no transparency needed on black) + fill_gif_dsc(&builtin_dsc, bg_gif_start, bg_gif_end - bg_gif_start); 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_gif_set_src(bg_gif, &builtin_dsc); lv_obj_center(bg_gif); // arc gauge along the round edge: 270° sweep, gap at the bottom @@ -321,6 +378,33 @@ static void build_settings_tile() make_button(tile_settings, "Done", 0, 296, 110, on_done_clicked); } +static void build_bg_tile() +{ + lv_obj_t *title = lv_label_create(tile_bg); + lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(title, COLOR_TEXT, 0); + lv_label_set_text(title, "Background"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20); + + bg_status_label = lv_label_create(tile_bg); + lv_obj_set_style_text_color(bg_status_label, COLOR_DIM, 0); + lv_obj_set_width(bg_status_label, 260); + lv_label_set_long_mode(bg_status_label, LV_LABEL_LONG_MODE_DOTS); + lv_obj_set_style_text_align(bg_status_label, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(bg_status_label, ""); + lv_obj_align(bg_status_label, LV_ALIGN_TOP_MID, 0, 48); + + bg_list = lv_list_create(tile_bg); + lv_obj_set_size(bg_list, 276, 214); + lv_obj_align(bg_list, LV_ALIGN_TOP_MID, 0, 74); + lv_obj_set_style_bg_color(bg_list, COLOR_PANEL, 0); + lv_obj_set_style_border_width(bg_list, 0, 0); + lv_obj_set_style_pad_all(bg_list, 4, 0); + + make_button(tile_bg, "Rescan", -50, 296, 92, on_bg_rescan_clicked); + make_button(tile_bg, "Done", 50, 296, 92, on_done_clicked); +} + static void build_ui() { lv_obj_set_style_bg_color(lv_screen_active(), lv_color_hex(0x000000), 0); @@ -329,11 +413,13 @@ static void build_ui() lv_obj_set_scrollbar_mode(tileview, LV_SCROLLBAR_MODE_OFF); tile_speed = lv_tileview_add_tile(tileview, 0, 0, LV_DIR_RIGHT); tile_menu = lv_tileview_add_tile(tileview, 1, 0, (lv_dir_t)(LV_DIR_LEFT | LV_DIR_RIGHT)); - tile_settings = lv_tileview_add_tile(tileview, 2, 0, LV_DIR_LEFT); + tile_settings = lv_tileview_add_tile(tileview, 2, 0, (lv_dir_t)(LV_DIR_LEFT | LV_DIR_RIGHT)); + tile_bg = lv_tileview_add_tile(tileview, 3, 0, LV_DIR_LEFT); lv_obj_add_event_cb(tileview, on_tile_changed, LV_EVENT_VALUE_CHANGED, NULL); build_speed_tile(); build_menu_tile(); build_settings_tile(); + build_bg_tile(); idle_overlay = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(idle_overlay); @@ -427,6 +513,62 @@ static void update_speed() } } +static lv_obj_t *add_bg_item(const String &text, lv_color_t color, int idx) +{ + lv_obj_t *btn = lv_list_add_button(bg_list, NULL, text.c_str()); + lv_obj_add_event_cb(btn, on_bg_item_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)idx); + lv_obj_set_style_bg_color(btn, lv_color_hex(0x222A33), 0); + lv_obj_set_style_pad_ver(btn, 6, 0); + lv_obj_set_style_text_color(btn, color, 0); + return btn; +} + +static void update_bg_page() +{ + String st = bg_status(); + if (st != lv_label_get_text(bg_status_label)) + lv_label_set_text(bg_status_label, st.c_str()); + + bg_get_list(bg_files, bg_list_version); + String sel = bg_selected(); + String key = sel + "|" + bg_list_version; + if (key == bg_list_key) + return; + bg_list_key = key; + lv_obj_clean(bg_list); + add_bg_item(String("Built-in") + (sel.length() == 0 ? " (active)" : ""), sel.length() == 0 ? COLOR_ARC : COLOR_TEXT, -1); + for (int i = 0; i < (int)bg_files.size() && i < MAX_BG_ITEMS; i++) + { + const BgFile &f = bg_files[i]; + bool active = f.name == sel; + String text = f.name + (active ? " (active)" : "") + "\n" + String(f.size / 1048576.0f, 2) + " MB " + f.w + "x" + f.h; + lv_color_t c = active ? COLOR_ARC : COLOR_TEXT; + if (f.w > 480) + { + text += " too wide"; + c = COLOR_DIM; + } + else if (f.w != 360 || f.h != 360) + { + text += " not 360x360"; + c = lv_color_hex(0xFF9500); + } + add_bg_item(text, c, i); + } +} + +static void update_bg_source() +{ + if (bg_release_requested()) + { + // the stored GIF in flash is about to be overwritten: play the built-in one meanwhile + set_gif_source(&builtin_dsc); + bg_release_done(); + } + if (bg_source_changed(bg_source_version)) + apply_background(); +} + static void update_menu() { String saved = obd_saved_name(); @@ -493,6 +635,8 @@ void setup() build_ui(); obd_set_idle_timeout(load_idle_minutes()); obd_begin(); + bg_begin(); + apply_background(); if (obd_saved_address().length() == 0) { // first start: go straight to the adapter menu @@ -512,6 +656,9 @@ void loop() update_speed(); update_voltage(); update_idle(); + update_bg_source(); + if (lv_tileview_get_tile_active(tileview) == tile_bg) + update_bg_page(); } if (now - last_menu >= 500 && menu_visible()) { diff --git a/CLAUDE.md b/CLAUDE.md index f75ac79..47b9398 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,8 +78,9 @@ 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. | +| `tools` | `gif_to_360.py` — converts GIFs to 360×360 for the device (Pillow; scale to cover + center crop, keeps frame timing) | | `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, battery-voltage arc in the bottom gap, idle/screen-off mode (both **verified by the user**); 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` | +| `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, idle/screen-off mode (both **verified by the user**); swipe left for the adapter menu (scan, select → remembered in NVS, auto-reconnect every 3 s), again for settings (rotation, idle/screen-off timeout), again for the background (GIF from SD `/gifs` copied into an 11.9 MB raw flash partition, own `partitions.csv`). 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 | diff --git a/tools/gif_to_360.py b/tools/gif_to_360.py new file mode 100644 index 0000000..150d23c --- /dev/null +++ b/tools/gif_to_360.py @@ -0,0 +1,71 @@ +"""Convert GIFs to 360x360 for the JC3636W518 (round 360x360 display). + +Each frame is scaled so the GIF covers 360x360, then center-cropped. Frame durations and looping are kept. +Pillow stores only the changed part of each frame, which keeps files small. + +Usage: + pip install pillow + python gif_to_360.py [more ...] -o + +The device stores one background GIF in an 11.5 MB flash area: bigger results are reported as too large. +""" +import argparse +import os +import sys + +from PIL import Image, ImageSequence + +SIZE = 360 +MAX_BYTES = 11_500_000 + + +def convert(src, dst): + im = Image.open(src) + w, h = im.size + scale = max(SIZE / w, SIZE / h) + nw, nh = max(SIZE, round(w * scale)), max(SIZE, round(h * scale)) + left, top = (nw - SIZE) // 2, (nh - SIZE) // 2 + + frames, durations = [], [] + for frame in ImageSequence.Iterator(im): + durations.append(frame.info.get("duration", im.info.get("duration", 100)) or 100) + rgba = frame.convert("RGBA") # fully composited frame + if (nw, nh) != (w, h): + rgba = rgba.resize((nw, nh), Image.LANCZOS) + rgba = rgba.crop((left, top, left + SIZE, top + SIZE)) + # the device shows the GIF on black: flatten transparency onto black + bg = Image.new("RGBA", rgba.size, (0, 0, 0, 255)) + bg.alpha_composite(rgba) + frames.append(bg.convert("RGB")) + + frames[0].save(dst, save_all=True, append_images=frames[1:], duration=durations, + loop=im.info.get("loop", 0), optimize=True, disposal=1) + size = os.path.getsize(dst) + fps = len(durations) * 1000 / max(1, sum(durations)) + note = "" if size <= MAX_BYTES else " ** TOO LARGE for the device (max 11.5 MB) **" + print(f"{os.path.basename(src)}: {w}x{h}, {len(frames)} frames, {fps:.1f} fps " + f"-> {os.path.basename(dst)} {size / 1e6:.2f} MB{note}") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("inputs", nargs="+", help="GIF files or folders") + ap.add_argument("-o", "--out", required=True, help="output folder") + args = ap.parse_args() + os.makedirs(args.out, exist_ok=True) + + files = [] + for p in args.inputs: + if os.path.isdir(p): + files += [os.path.join(p, f) for f in sorted(os.listdir(p)) if f.lower().endswith(".gif")] + else: + files.append(p) + if not files: + sys.exit("no GIF files found") + for f in files: + name = os.path.splitext(os.path.basename(f))[0] + convert(f, os.path.join(args.out, name + ".gif")) + + +if __name__ == "__main__": + main()