diff --git a/docs/development/l2-roadmap.md b/docs/development/l2-roadmap.md index d9c69e98..4b24a359 100644 --- a/docs/development/l2-roadmap.md +++ b/docs/development/l2-roadmap.md @@ -225,7 +225,26 @@ Ideas to come back to (2026-09-26): remove it? - [ ] Import routes from the SD card (optional extra). - [ ] Battery life without losing features, above all CPU sleep. -- [ ] Live tiles: don't keep them, or give them a bounded cache on the card - so they never fill it. +- [x] Live tiles: bounded cache on the card (2026-09-26) -- they go to + /sdcard/maps-live, apart from downloaded maps; an index keeps their + order and the oldest are deleted past the limit (Settings > Storage > + Live map tiles: 16 / 64 / 256 MB / 1 GB, default 64 MB; Delete). + Live tiles saved by earlier builds sit in /sdcard/maps and can't be + told apart from downloaded ones. - [ ] Vector maps and other tile sources (to think through). +User list, second batch (2026-09-26): + +- [ ] Status bar icons look like different sizes? +- [ ] Under own messages: a small counter as in the original (L1), not a + checkmark with "relayed". +- [ ] Polish the path popup of a sent / received message. +- [ ] Map download popup runs off the screen; take the WiFi settings out + of it (one place: Settings > WiFi). +- [ ] Splash: "MeshCore Solo" with the upstream version and ours, all in + the MeshCore title font. +- [ ] Emoji, ideally the full range (glyphs could live on the SD card). +- [ ] Tap-to-wake toggle. +- [ ] Favourites as their own screen in the menu rather than a tile. +- [ ] WiFi indicator icon in the status bar. + diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index c596b160..e0ddceea 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -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; } diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index a80fee91..5038678f 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -17,11 +17,12 @@ #include #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(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; diff --git a/examples/companion_radio/ui-lvgl/StorageScreen.h b/examples/companion_radio/ui-lvgl/StorageScreen.h index 857e066c..782d6807 100644 --- a/examples/companion_radio/ui-lvgl/StorageScreen.h +++ b/examples/companion_radio/ui-lvgl/StorageScreen.h @@ -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"); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 23dcc9c6..35a1e16d 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -35,6 +35,7 @@ template 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); } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 41d8e964..8d71882b 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/map/LiveCache.h b/examples/companion_radio/ui-lvgl/map/LiveCache.h new file mode 100644 index 00000000..9ba21a16 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/map/LiveCache.h @@ -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 +#include + +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 diff --git a/examples/companion_radio/ui-lvgl/map/TileDownloader.h b/examples/companion_radio/ui-lvgl/map/TileDownloader.h index 5fd9455a..bd5c4462 100644 --- a/examples/companion_radio/ui-lvgl/map/TileDownloader.h +++ b/examples/companion_radio/ui-lvgl/map/TileDownloader.h @@ -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 +// , 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"); diff --git a/examples/companion_radio/ui-lvgl/map/TileProvider.h b/examples/companion_radio/ui-lvgl/map/TileProvider.h index 87bc6609..c9f75d80 100644 --- a/examples/companion_radio/ui-lvgl/map/TileProvider.h +++ b/examples/companion_radio/ui-lvgl/map/TileProvider.h @@ -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 has no tile. + explicit RasterTileProvider(const char* root, const char* live = nullptr) : _root(root), _live(live) {} // Also reads /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);