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 <noreply@anthropic.com>
This commit is contained in:
patriandClaude Opus 5.5 committed 2026-10-23 18:58:44 +02:00
1 parent 9d5561be5a
commit c9fb70ad10
9 files changed
+651 -17

No files matched your search

+3
View File
@@ -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/
+17 -2
View File
@@ -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 <gif files or folders> -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.
+6
View File
@@ -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,
1 # JC3636W518 OBD speed: one 4 MB app (no OTA) + 11.9 MB raw area for the background GIF (written/mmapped by bg_store.cpp)
2 # Name, Type, SubType, Offset, Size
3 nvs, data, nvs, 0x9000, 0x6000,
4 app0, app, factory, 0x10000, 0x400000,
5 bggif, data, 0x40, 0x410000, 0xBE0000,
6 coredump, data, coredump, 0xFF0000, 0x10000,
+2 -2
View File
@@ -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 =
+353
View File
@@ -0,0 +1,353 @@
#include "bg_store.h"
#include <FS.h>
#include <Preferences.h>
#include <SD_MMC.h>
#include <esp_partition.h>
#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<BgFile> 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<BgFile> 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/<name> 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/<name>. 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<BgFile> &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;
}
+38
View File
@@ -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 <Arduino.h>
#include <vector>
#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<BgFile> &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);
+159 -12
View File
@@ -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 <Arduino.h>
#include <Preferences.h>
#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<BgFile> 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())
{
+2 -1
View File
@@ -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 |
+71
View File
@@ -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 <input.gif or folder> [more ...] -o <output folder>
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()