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:
1 parent
9d5561be5a
commit
c9fb70ad10
9 files changed
+651
-17
No files matched your search
@@ -4,3 +4,6 @@
|
|||||||
.vscode/c_cpp_properties.json
|
.vscode/c_cpp_properties.json
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
.vscode/ipch
|
.vscode/ipch
|
||||||
|
|
||||||
|
# generated by tools/gif_to_360.py (copy to the SD card, regenerate any time)
|
||||||
|
12-OBD-Speed/sd/
|
||||||
+17
-2
@@ -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.
|
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.
|
**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 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
|
- **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).
|
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).
|
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/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/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) |
|
| `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.
|
||||||
@@ -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,
|
||||||
|
@@ -9,8 +9,8 @@ dir = ../shared
|
|||||||
|
|
||||||
[env:jc3636w518]
|
[env:jc3636w518]
|
||||||
extends = jc3636w518
|
extends = jc3636w518
|
||||||
; firmware + 1.6 MB background GIF does not fit huge_app (3 MB): 6.25 MB app slots
|
; 4 MB app (no OTA) + 11.9 MB raw 'bggif' area for the background GIF chosen from the SD card
|
||||||
board_build.partitions = default_16MB.csv
|
board_build.partitions = partitions.csv
|
||||||
; background GIF linked into the firmware as-is (symbols _binary_data_bg_gif_start/_end)
|
; background GIF linked into the firmware as-is (symbols _binary_data_bg_gif_start/_end)
|
||||||
board_build.embed_files = data/bg.gif
|
board_build.embed_files = data/bg.gif
|
||||||
lib_deps =
|
lib_deps =
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -1,17 +1,20 @@
|
|||||||
// OBD speed display for the JC3636W518: vehicle speed from an ELM327 over BLE on top of an animated GIF.
|
// 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 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
|
// 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.
|
// screen goes dark (idle) and wakes on touch or when the engine starts.
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <Preferences.h>
|
#include <Preferences.h>
|
||||||
#include "jc3636w518.h"
|
#include "jc3636w518.h"
|
||||||
#include "obd_link.h"
|
#include "obd_link.h"
|
||||||
|
#include "bg_store.h"
|
||||||
|
|
||||||
#define SPEED_MAX 200
|
#define SPEED_MAX 200
|
||||||
#define VOLT_MIN_X10 110 // voltage arc range 11.0 .. 15.0 V
|
#define VOLT_MIN_X10 110 // voltage arc range 11.0 .. 15.0 V
|
||||||
#define VOLT_MAX_X10 150
|
#define VOLT_MAX_X10 150
|
||||||
#define MAX_MENU_ITEMS 30
|
#define MAX_MENU_ITEMS 30
|
||||||
|
#define MAX_BG_ITEMS 40
|
||||||
|
|
||||||
LV_FONT_DECLARE(font_speed_120);
|
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_OBD = lv_color_hex(0x4CD964);
|
||||||
static const lv_color_t COLOR_PANEL = lv_color_hex(0x161C22);
|
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 lv_obj_t *rot_buttons[4], *idle_buttons[4];
|
||||||
static const uint8_t IDLE_MINUTES[4] = {0, 2, 5, 10}; // 0 = never
|
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 *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)
|
static void on_tile_changed(lv_event_t *e)
|
||||||
{
|
{
|
||||||
obd_scan_enable(menu_visible()); // scan only while the menu is open
|
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)
|
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)
|
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 ----------
|
// ---------- speed page ----------
|
||||||
static void build_speed_tile()
|
static void build_speed_tile()
|
||||||
{
|
{
|
||||||
// background GIF, decoded straight into the display format (no transparency needed on black)
|
// background GIF (built-in until apply_background() picks the stored one),
|
||||||
static lv_image_dsc_t gif_dsc;
|
// decoded straight into the display format (no transparency needed on black)
|
||||||
gif_dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
|
fill_gif_dsc(&builtin_dsc, bg_gif_start, bg_gif_end - bg_gif_start);
|
||||||
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;
|
|
||||||
bg_gif = lv_gif_create(tile_speed);
|
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_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);
|
lv_obj_center(bg_gif);
|
||||||
|
|
||||||
// arc gauge along the round edge: 270° sweep, gap at the bottom
|
// 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);
|
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()
|
static void build_ui()
|
||||||
{
|
{
|
||||||
lv_obj_set_style_bg_color(lv_screen_active(), lv_color_hex(0x000000), 0);
|
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);
|
lv_obj_set_scrollbar_mode(tileview, LV_SCROLLBAR_MODE_OFF);
|
||||||
tile_speed = lv_tileview_add_tile(tileview, 0, 0, LV_DIR_RIGHT);
|
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_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);
|
lv_obj_add_event_cb(tileview, on_tile_changed, LV_EVENT_VALUE_CHANGED, NULL);
|
||||||
build_speed_tile();
|
build_speed_tile();
|
||||||
build_menu_tile();
|
build_menu_tile();
|
||||||
build_settings_tile();
|
build_settings_tile();
|
||||||
|
build_bg_tile();
|
||||||
|
|
||||||
idle_overlay = lv_obj_create(lv_layer_top());
|
idle_overlay = lv_obj_create(lv_layer_top());
|
||||||
lv_obj_remove_style_all(idle_overlay);
|
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()
|
static void update_menu()
|
||||||
{
|
{
|
||||||
String saved = obd_saved_name();
|
String saved = obd_saved_name();
|
||||||
@@ -493,6 +635,8 @@ void setup()
|
|||||||
build_ui();
|
build_ui();
|
||||||
obd_set_idle_timeout(load_idle_minutes());
|
obd_set_idle_timeout(load_idle_minutes());
|
||||||
obd_begin();
|
obd_begin();
|
||||||
|
bg_begin();
|
||||||
|
apply_background();
|
||||||
if (obd_saved_address().length() == 0)
|
if (obd_saved_address().length() == 0)
|
||||||
{
|
{
|
||||||
// first start: go straight to the adapter menu
|
// first start: go straight to the adapter menu
|
||||||
@@ -512,6 +656,9 @@ void loop()
|
|||||||
update_speed();
|
update_speed();
|
||||||
update_voltage();
|
update_voltage();
|
||||||
update_idle();
|
update_idle();
|
||||||
|
update_bg_source();
|
||||||
|
if (lv_tileview_get_tile_active(tileview) == tile_bg)
|
||||||
|
update_bg_page();
|
||||||
}
|
}
|
||||||
if (now - last_menu >= 500 && menu_visible())
|
if (now - last_menu >= 500 && menu_visible())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) |
|
| `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. |
|
| `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` |
|
| `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 |
|
| `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) |
|
| `3-Specification` | Spec sheet (factory firmware features, interface photo) |
|
||||||
| `4-Chip manual` | ESP32-S3R8 datasheet, PCM5100A datasheet |
|
| `4-Chip manual` | ESP32-S3R8 datasheet, PCM5100A datasheet |
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user