feat(ui-lvgl): live map tiles kept apart, under a size limit

Tiles fetched while browsing online go to /sdcard/maps-live with an
index of arrival order; past the limit (Settings > Storage, 16 MB-1 GB,
default 64 MB) the oldest are deleted a few per loop. Storage shows
their count and size and can delete them all. Downloaded maps are never
touched. Roadmap: live tiles done, second batch of user items added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 22:25:02 +02:00
co-authored by Claude Opus 5.5
parent f849ee0b00
commit 7a34f68369
9 changed files with 263 additions and 14 deletions
@@ -212,6 +212,20 @@ static void saveHistKeep(int idx) {
p.putChar("hkeep", (int8_t)idx);
p.end();
}
// Settings > Storage > Live map tiles: index into mapview::LIVE_CAP_MB, -1 = default.
static int loadLiveCap() {
Preferences p;
if (!p.begin("mc_ui", true)) return -1;
int v = p.getChar("ltcap", -1);
p.end();
return v;
}
static void saveLiveCap(int idx) {
Preferences p;
if (!p.begin("mc_ui", false)) return;
p.putChar("ltcap", (int8_t)idx);
p.end();
}
// Filesystem size and space in use (Settings > Storage), plus the card's own
// size -- a card whose FAT partition is small (e.g. written by a Raspberry Pi
// imager) shows both. The first free-space count on a big card takes a moment.
@@ -508,6 +522,9 @@ static void saveAccent(int idx) { s_accent = idx; }
static int s_hist_keep = -1;
static int loadHistKeep() { return s_hist_keep; }
static void saveHistKeep(int idx) { s_hist_keep = idx; }
static int s_live_cap = -1;
static int loadLiveCap() { return s_live_cap; }
static void saveLiveCap(int idx) { s_live_cap = idx; }
// The browser has no card: a nominal 32 GB, used = what the files add up to
// (the storage screen counts them anyway; it passes that in).
static bool sdInfo(uint64_t& total, uint64_t& used, uint64_t& card) { total = card = 32ULL << 30; used = 0; return true; }
+3 -1
View File
@@ -17,11 +17,12 @@
#include <math.h>
#include "map/TileProvider.h"
#include "map/TileCache.h"
#include "map/LiveCache.h"
#include "map/TileDownloader.h"
namespace mapview {
static RasterTileProvider s_raster("/sdcard/maps");
static RasterTileProvider s_raster("/sdcard/maps", LIVE_ROOT);
static TileProvider* s_provider = &s_raster; // the one place to swap in a vector renderer
static TileCache& s_cache = *new (psramBuf<TileCache>(1)) TileCache(); // decoded tiles, in PSRAM
static TileDownloader s_dl("/sdcard/maps");
@@ -787,6 +788,7 @@ void UITask::setLiveTiles(bool on) {
void UITask::mapDownloadTick() {
mapview::TileDownloader& dl = mapview::s_dl;
dl.loop();
mapview::s_live_cache.service(6); // trims live tiles past the Storage limit
if (dl.liveOn()) {
int z, x, y;
bool got = false;
@@ -2,7 +2,7 @@
// Settings > Storage: how full the SD card and the internal flash are and
// what takes the card's space (maps, message history, GPX trails, the rest),
// how many messages each conversation keeps (HistoryStore.h) and deleting
// the history.
// the history, and how much space live map tiles may take (map/LiveCache.h).
//
// The card's files are counted in the background while the screen is open
// (a map can be tens of thousands of tiles), a few milliseconds per loop.
@@ -11,8 +11,8 @@
namespace storeview {
enum Cat : uint8_t { C_MAPS, C_MSGS, C_TRAILS, C_OTHER, C_COUNT };
static const char* const CAT_NAME[C_COUNT] = { "Maps", "Messages", "Trails (GPX)", "Other" };
enum Cat : uint8_t { C_MAPS, C_LIVE, C_MSGS, C_TRAILS, C_OTHER, C_COUNT };
static const char* const CAT_NAME[C_COUNT] = { "Maps", "Live map tiles", "Messages", "Trails (GPX)", "Other" };
static uint64_t s_bytes[C_COUNT];
static uint32_t s_files = 0;
@@ -34,6 +34,9 @@ static lv_obj_t* s_cat_val[C_COUNT];
static lv_obj_t* s_status = nullptr;
static lv_obj_t* s_clear_lbl = nullptr;
static uint32_t s_clear_armed_ms = 0;
static lv_obj_t* s_live_lbl = nullptr; // "Delete live map tiles" row label
static lv_obj_t* s_live_info = nullptr; // count / size from the index
static uint32_t s_live_armed_ms = 0;
static uint32_t s_shown_ms = 0;
static void fmtBytes(char* out, size_t n, uint64_t b) {
@@ -65,6 +68,7 @@ static void startWalk() {
// What a top-level folder counts as.
static uint8_t topCat(const char* name) {
if (!strcmp(name, "maps")) return C_MAPS;
if (!strcmp(name, "maps-live")) return C_LIVE;
if (!strcmp(name, "meshcore")) return C_MSGS;
if (!strcmp(name, "trails")) return C_TRAILS;
return C_OTHER;
@@ -193,6 +197,10 @@ static void onHistKeep(lv_event_t* e) {
s_ui->storageKeep((int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e)));
}
static void onHistClear(lv_event_t* e) { (void)e; s_ui->storageClearHistory(); }
static void onLiveCap(lv_event_t* e) {
s_ui->storageLiveCap((int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e)));
}
static void onLiveClear(lv_event_t* e) { (void)e; s_ui->storageClearLive(); }
void UITask::showStorage() {
_screen = SCR_STORAGE;
@@ -202,7 +210,7 @@ void UITask::showStorage() {
void UITask::buildStorage() {
using namespace storeview;
lv_obj_t* body = newScreen("Storage", true);
s_sd_bar = s_sd_lbl = s_status = s_clear_lbl = nullptr;
s_sd_bar = s_sd_lbl = s_status = s_clear_lbl = s_live_lbl = s_live_info = nullptr;
memset(s_cat_val, 0, sizeof(s_cat_val));
s_clear_armed_ms = 0;
@@ -245,6 +253,21 @@ void UITask::buildStorage() {
lv_obj_t* row = listRow(body, LV_SYMBOL_TRASH " Delete message history", "Every chat, on the card and in memory",
onHistClear, NULL);
s_clear_lbl = lv_obj_get_child(row, 0);
sectionTitle(body, "LIVE MAP TILES");
int cap_idx = mapview::LIVE_CAP_DEFAULT;
for (int i = 0; i < mapview::LIVE_CAP_COUNT; i++)
if (((uint64_t)mapview::LIVE_CAP_MB[i] << 20) == mapview::s_live_cache.limit()) cap_idx = i;
lv_obj_t* cd = dropdownRow(body, "Keep up to", mapview::LIVE_CAP_OPTS, cap_idx);
lv_obj_remove_event_cb(cd, onKeyboardAlphabet);
lv_obj_add_event_cb(cd, onLiveCap, LV_EVENT_VALUE_CHANGED, NULL);
s_live_info = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(s_live_info, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_live_info, LV_PCT(100));
lv_obj_t* lrow = listRow(body, LV_SYMBOL_TRASH " Delete live map tiles", "Downloaded maps stay",
onLiveClear, NULL);
s_live_lbl = lv_obj_get_child(lrow, 0);
s_live_armed_ms = 0;
s_shown_ms = 0;
}
@@ -274,6 +297,18 @@ void UITask::pollStorage() {
if (used == 0) for (int c = 0; c < C_COUNT; c++) used += s_bytes[c]; // the sim counts files
showUsage(s_sd_bar, s_sd_lbl, s_sd_total, used);
}
if (s_live_info) {
char b[16], t[96];
fmtBytes(b, sizeof(b), mapview::s_live_cache.bytes());
if (mapview::s_live_cache.clearing()) snprintf(t, sizeof(t), "Deleting... %lu left", (unsigned long)mapview::s_live_cache.count());
else snprintf(t, sizeof(t), "Fetched while browsing online: %lu tiles, %s. Past the limit, the oldest go first.",
(unsigned long)mapview::s_live_cache.count(), b);
lv_label_set_text(s_live_info, t);
}
if (s_live_armed_ms && millis() - s_live_armed_ms > 4000) {
s_live_armed_ms = 0;
if (s_live_lbl) lv_label_set_text(s_live_lbl, LV_SYMBOL_TRASH " Delete live map tiles");
}
if (s_clear_armed_ms && millis() - s_clear_armed_ms > 4000) { // the confirm tap timed out
s_clear_armed_ms = 0;
if (s_clear_lbl) lv_label_set_text(s_clear_lbl, LV_SYMBOL_TRASH " Delete message history");
@@ -304,3 +339,23 @@ void UITask::storageClearHistory() {
showToast("Message history deleted");
startWalk();
}
void UITask::storageLiveCap(int idx) {
if (idx < 0 || idx >= mapview::LIVE_CAP_COUNT) return;
lvport::saveLiveCap(idx);
mapview::s_live_cache.setLimit((uint64_t)mapview::LIVE_CAP_MB[idx] << 20); // trimmed in the background
showToast("Saved");
}
void UITask::storageClearLive() {
using namespace storeview;
if (!s_live_armed_ms) { // first tap: ask
s_live_armed_ms = millis();
if (s_live_lbl) lv_label_set_text(s_live_lbl, LV_SYMBOL_TRASH " Tap again to delete");
return;
}
s_live_armed_ms = 0;
mapview::s_live_cache.clearAll(); // a few files per loop (mapDownloadTick)
if (s_live_lbl) lv_label_set_text(s_live_lbl, LV_SYMBOL_TRASH " Delete live map tiles");
showToast("Deleting live map tiles");
}
@@ -35,6 +35,7 @@ template <class T> static T* psramBuf(size_t n) {
#include "Anim.h"
#include "LvglPort.h"
#include "HistoryStore.h"
#include "map/LiveCache.h"
static histstore::SdArchive s_archive; // message history on the SD card
namespace storeview { static void stopWalk(); } // StorageScreen.h
#include "../ui-core/KeyboardData.h"
@@ -216,6 +217,8 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs
{ // history kept on the SD card: back into the ring, then every new entry to it
int hk = lvport::loadHistKeep();
s_archive.setKeep(histstore::KEEP[hk >= 0 && hk < histstore::KEEP_COUNT ? hk : histstore::KEEP_DEFAULT]);
int lc = lvport::loadLiveCap();
mapview::s_live_cache.setLimit((uint64_t)mapview::LIVE_CAP_MB[lc >= 0 && lc < mapview::LIVE_CAP_COUNT ? lc : mapview::LIVE_CAP_DEFAULT] << 20);
s_archive.restore(_core->history);
_core->history.setArchive(&s_archive);
}
@@ -224,6 +224,8 @@ public:
void pollStorage();
void storageKeep(int idx);
void storageClearHistory();
void storageLiveCap(int idx);
void storageClearLive();
bool locked() const;
void pinKey(const char* key); // the lock screen's keypad
void pinSetupPopup(); // Settings > Display & power > Screen PIN
@@ -0,0 +1,139 @@
#pragma once
// Live map tiles (fetched while browsing online) live apart from downloaded
// maps, under LIVE_ROOT/{z}/{x}/{y}.png, and only up to a size limit
// (Settings > Storage): an index remembers the order they came in, and the
// oldest are deleted once the tiles add up to more than the limit. Maps
// downloaded on purpose (TileDownloader jobs, the PC tool) are never touched.
//
// Index: LIVE_ROOT/index.bin, a header and a ring of RING records. Eviction
// runs a few files per loop (service()), so lowering the limit or clearing
// thousands of tiles never blocks the UI.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp only (after LvglPort.h).
#include <stdio.h>
#include <sys/stat.h>
namespace mapview {
static const char* const LIVE_ROOT = "/sdcard/maps-live";
// Settings > Storage > Live map tiles: "Keep up to".
static const uint32_t LIVE_CAP_MB[] = { 16, 64, 256, 1024 };
static const int LIVE_CAP_COUNT = sizeof(LIVE_CAP_MB) / sizeof(LIVE_CAP_MB[0]);
static const int LIVE_CAP_DEFAULT = 1;
static const char* const LIVE_CAP_OPTS = "16 MB\n64 MB\n256 MB\n1 GB";
class LiveCache {
public:
static const uint32_t RING = 16384; // tiles; ~400 MB of typical tiles, 256 KB of index
void setLimit(uint64_t bytes) { _limit = bytes; }
uint64_t limit() const { return _limit; }
uint64_t bytes() { load(); return _h.bytes; }
uint32_t count() { load(); return _h.count; }
bool clearing() const { return _clearing; }
// A tile was written to LIVE_ROOT: remember it (eviction follows in service()).
void add(int z, int x, int y, uint32_t size) {
load();
if (_h.count >= RING) evictOne(); // the ring is full: make room right away
Rec r = { (int32_t)z, x, y, size };
uint32_t slot = (_h.head + _h.count) % RING;
_h.count++;
_h.bytes += size;
writeRec(slot, r);
writeHdr();
}
// Delete every live tile (a few per service() call).
void clearAll() { load(); _clearing = true; }
// Evicts while over the limit (or clearing), for up to `budget_ms`.
void service(uint32_t budget_ms) {
if (!_loaded && !_clearing) return; // nothing written or asked for yet
load();
uint32_t t0 = millis();
bool changed = false;
while ((_clearing ? _h.count > 0 : _h.bytes > _limit) && millis() - t0 < budget_ms) {
evictOne(false);
changed = true;
}
if (changed) writeHdr();
if (_clearing && _h.count == 0) { _clearing = false; _h.bytes = 0; writeHdr(); }
}
static void tilePath(char* out, size_t n, int z, int x, int y) {
snprintf(out, n, "%s/%d/%d/%d.png", LIVE_ROOT, z, x, y);
}
private:
struct Hdr { uint32_t magic, ring, head, count; uint64_t bytes; };
struct Rec { int32_t z, x, y; uint32_t size; };
static const uint32_t MAGIC = 0x3143544C; // "LTC1"
Hdr _h = { MAGIC, RING, 0, 0, 0 };
bool _loaded = false, _clearing = false;
uint64_t _limit = 64ULL << 20;
static void indexPath(char* out, size_t n) { snprintf(out, n, "%s/index.bin", LIVE_ROOT); }
void load() {
if (_loaded) return;
_loaded = true;
char p[128];
indexPath(p, sizeof(p));
FILE* f = fopen(p, "rb");
Hdr h;
if (f && fread(&h, sizeof(h), 1, f) == 1 && h.magic == MAGIC && h.ring == RING && h.count <= RING && h.head < RING) _h = h;
else _h = { MAGIC, RING, 0, 0, 0 };
if (f) fclose(f);
}
FILE* openIndex() {
char p[128];
indexPath(p, sizeof(p));
FILE* f = fopen(p, "r+b");
if (!f) {
mkdir(LIVE_ROOT, 0777);
f = fopen(p, "w+b");
}
return f;
}
void writeHdr() {
if (FILE* f = openIndex()) { fwrite(&_h, sizeof(_h), 1, f); fclose(f); }
}
void writeRec(uint32_t slot, const Rec& r) {
if (FILE* f = openIndex()) {
if (fseek(f, (long)(sizeof(Hdr) + slot * sizeof(Rec)), SEEK_SET) == 0) fwrite(&r, sizeof(r), 1, f);
fclose(f);
}
}
bool readRec(uint32_t slot, Rec& r) {
char p[128];
indexPath(p, sizeof(p));
FILE* f = fopen(p, "rb");
if (!f) return false;
bool ok = fseek(f, (long)(sizeof(Hdr) + slot * sizeof(Rec)), SEEK_SET) == 0 && fread(&r, sizeof(r), 1, f) == 1;
fclose(f);
return ok;
}
void evictOne(bool save = true) {
if (_h.count == 0) return;
Rec r;
if (readRec(_h.head, r)) {
char path[128];
tilePath(path, sizeof(path), r.z, r.x, r.y);
remove(path);
_h.bytes = _h.bytes > r.size ? _h.bytes - r.size : 0;
}
_h.head = (_h.head + 1) % RING;
_h.count--;
if (save) writeHdr();
}
};
static LiveCache s_live_cache;
} // namespace mapview
@@ -20,7 +20,8 @@
// Live tiles: while the map is open, single tiles it is missing are fetched
// the same way (one at a time, between the job's steps when there is no job),
// the WiFi connected on the first miss and dropped by the caller (liveEnd)
// once the map has been closed for a while.
// once the map has been closed for a while. They go to LIVE_ROOT, not
// <root>, and only up to the Storage limit (LiveCache.h).
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp only (after LvglPort.h).
@@ -317,7 +318,10 @@ private:
_lv_fetching = false;
size_t len = 0;
const uint8_t* data = r > 0 ? lvport::fetchData(len) : nullptr;
if (r > 0 && looksLikeImage(data, len) && !isNoTilePicture(data, len) && writeTile(_lv_cur.z, _lv_cur.x, _lv_cur.y, data, len)) {
char lpath[64];
LiveCache::tilePath(lpath, sizeof(lpath), _lv_cur.z, _lv_cur.x, _lv_cur.y);
if (r > 0 && looksLikeImage(data, len) && !isNoTilePicture(data, len) && writeFile(lpath, data, len)) {
s_live_cache.add(_lv_cur.z, _lv_cur.x, _lv_cur.y, (uint32_t)len); // kept apart, within the Storage limit
_lv_consec_fail = 0;
if (_lv_done_n < LV_DONE) _lv_done[_lv_done_n++] = _lv_cur;
} else {
@@ -476,8 +480,12 @@ private:
}
bool writeTile(int z, int x, int y, const uint8_t* d, size_t n) {
char path[96], tmp[100];
char path[96];
tilePath(path, sizeof(path), z, x, y);
return writeFile(path, d, n);
}
static bool writeFile(const char* path, const uint8_t* d, size_t n) {
char tmp[100];
makeParents(path);
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
FILE* f = fopen(tmp, "wb");
@@ -47,7 +47,9 @@ public:
// which copies onto a FAT card far faster than millions of loose files.
class RasterTileProvider : public TileProvider {
public:
explicit RasterTileProvider(const char* root) : _root(root) {}
// `live`: a second folder in the same layout (live tiles, LiveCache.h), read
// when <root> has no tile.
explicit RasterTileProvider(const char* root, const char* live = nullptr) : _root(root), _live(live) {}
// Also reads <root>/attribution.txt (written by tools/maps/fetch_tiles.py and
// the on-device downloader): the tile set's credit line, shown on the map.
@@ -68,8 +70,9 @@ public:
bool renderTile(int z, int x, int y, uint16_t* out) override {
uint32_t len = 0;
uint8_t* png = readLoose(z, x, y, len);
uint8_t* png = readLoose(_root, z, x, y, len);
if (!png) png = readPacked(z, x, y, len);
if (!png && _live) png = readLoose(_live, z, x, y, len);
if (!png) return false;
unsigned char* res = nullptr;
@@ -94,6 +97,7 @@ public:
private:
const char* _root;
const char* _live;
char _attr[96] = "";
static uint8_t* readRange(FILE* f, long off, uint32_t len) {
@@ -104,9 +108,9 @@ private:
return buf;
}
uint8_t* readLoose(int z, int x, int y, uint32_t& len) {
uint8_t* readLoose(const char* root, int z, int x, int y, uint32_t& len) {
char path[64];
snprintf(path, sizeof(path), "%s/%d/%d/%d.png", _root, z, x, y);
snprintf(path, sizeof(path), "%s/%d/%d/%d.png", root, z, x, y);
FILE* f = fopen(path, "rb");
if (!f) return nullptr;
fseek(f, 0, SEEK_END);