feat(ui-lvgl): offline raster map from the microSD

Map screen (Home > Map): Web-Mercator tiles from /maps/{z}/{x}/{y}.png on
the card (Meshtastic MUI layout; per-column TPK1 .pak also read), drag to
pan, +/- zoom, re-centre/follow GPS, Nearby nodes with a position as
markers opening node detail. Tiles decode one per loop pass into a
12-slot RGB565 cache in PSRAM. map/TileProvider.h is the swap point for a
later vector renderer: the screen only calls renderTile(). The tile set's
credit line comes from maps/attribution.txt.

L2 mounts the card over SDMMC 1-bit (CLK 2, CMD 3, D0 1). LVGL's lodepng
is enabled; note its LVGL patch returns an lv_draw_buf_t*, not pixels.
LVGL pool 2 MB for PNG decoding. Fonts gain map / users icons.

tools/maps/fetch_tiles.py prepares a card for an area (presets for
Poland), requires an explicit tile URL (tile.openstreetmap.org blocks
offline use) and stops on placeholder images. The LVGL sim preloads
web/maps into the L2's /sdcard and puts its GPS on Kraków.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-25 10:57:46 +02:00
co-authored by Claude Opus 5.5
parent 3e5ccd28c7
commit 64321bf12b
18 changed files with 1021 additions and 133 deletions
@@ -0,0 +1,71 @@
#pragma once
// Decoded map tiles (RGB565, 128 KB each) kept for reuse while panning / zooming
// back, least-recently-used evicted. Buffers live in PSRAM on the ESP32 and are
// allocated on first use, then kept for the session.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp only.
#if defined(ESP32)
#include <esp_heap_caps.h>
#endif
extern "C" void lv_image_cache_drop(const void* src); // not exported through lvgl.h here
namespace mapview {
class TileCache {
public:
static const int SLOTS = 12; // 6 on screen at 320x218 + a ring of neighbours
struct Slot {
int16_t z = -1;
int32_t x = 0, y = 0;
bool present = false; // false: looked up, there is no tile (don't retry)
uint32_t used = 0;
uint16_t* px = nullptr;
lv_image_dsc_t dsc;
};
// The slot holding z/x/y if it was loaded (present or known missing), else nullptr.
Slot* find(int z, int x, int y) {
for (Slot& s : _slots)
if (s.z == z && s.x == x && s.y == y) { s.used = ++_tick; return &s; }
return nullptr;
}
// Render z/x/y through `src` into the least recently used slot.
Slot* load(TileProvider& src, int z, int x, int y) {
Slot* s = &_slots[0];
for (Slot& c : _slots) if (c.used < s->used) s = &c;
if (!s->px) {
#if defined(ESP32)
s->px = (uint16_t*)heap_caps_malloc(TILE_PX * TILE_PX * 2, MALLOC_CAP_SPIRAM);
#else
s->px = (uint16_t*)malloc(TILE_PX * TILE_PX * 2);
#endif
if (!s->px) return nullptr;
}
lv_image_cache_drop(&s->dsc); // same descriptor, new pixels
s->z = z; s->x = x; s->y = y;
s->used = ++_tick;
s->present = src.renderTile(z, x, y, s->px);
memset(&s->dsc, 0, sizeof(s->dsc));
s->dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
s->dsc.header.cf = LV_COLOR_FORMAT_RGB565;
s->dsc.header.w = TILE_PX;
s->dsc.header.h = TILE_PX;
s->dsc.header.stride = TILE_PX * 2;
s->dsc.data_size = TILE_PX * TILE_PX * 2;
s->dsc.data = (const uint8_t*)s->px;
return s;
}
// Forget everything (e.g. the card was swapped); keeps the buffers.
void invalidate() { for (Slot& s : _slots) { s.z = -1; s.used = 0; } }
private:
Slot _slots[SLOTS];
uint32_t _tick = 0;
};
} // namespace mapview
@@ -0,0 +1,130 @@
#pragma once
// Map tile sources. The map screen, its tile cache and the overlays only ever
// call renderTile(): 256x256 RGB565 pixels for Web-Mercator tile z/x/y. The
// raster provider decodes PNG files from the card; a vector renderer would be
// another TileProvider drawing into the same buffer, with nothing else changing.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp only.
#include <stdio.h>
#include <sys/stat.h>
// LVGL's copy of lodepng (LV_USE_LODEPNG), compiled as C. It is patched for
// LVGL: the decode "output" is an lv_draw_buf_t* (32-bit RGBA pixels in ->data),
// not a plain pixel array, and is freed with lv_draw_buf_destroy().
#define LODEPNG_NO_COMPILE_CPP
extern "C" {
#include "src/libs/lodepng/lodepng.h"
}
namespace mapview {
static const int TILE_PX = 256;
class TileProvider {
public:
virtual ~TileProvider() {}
virtual bool available() = 0; // anything to draw at all
virtual bool renderTile(int z, int x, int y, uint16_t* out) = 0; // false: no tile there
virtual const char* attribution() const = 0;
};
// Raster tiles under <root>/{z}/{x}/{y}.png -- the Meshtastic MUI layout, so
// cards prepared for either work for both (tools/maps/fetch_tiles.py writes
// it) -- or packed per column in <root>/{z}/{x}.pak ('TPK1': y_min, y_max,
// offsets[n+1], PNG blobs; the format of upstream PR #3381's tile_packer.py),
// 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) {}
// 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.
bool available() override {
struct stat st;
if (stat(_root, &st) != 0 || !S_ISDIR(st.st_mode)) return false;
char path[64];
snprintf(path, sizeof(path), "%s/attribution.txt", _root);
_attr[0] = '\0';
if (FILE* f = fopen(path, "r")) {
size_t n = fread(_attr, 1, sizeof(_attr) - 1, f);
fclose(f);
while (n > 0 && (_attr[n - 1] == '\n' || _attr[n - 1] == '\r')) n--;
_attr[n] = '\0';
}
return true;
}
bool renderTile(int z, int x, int y, uint16_t* out) override {
uint32_t len = 0;
uint8_t* png = readLoose(z, x, y, len);
if (!png) png = readPacked(z, x, y, len);
if (!png) return false;
unsigned char* res = nullptr;
unsigned w = 0, h = 0;
unsigned err = lodepng_decode32(&res, &w, &h, png, len);
lv_free(png);
lv_draw_buf_t* db = (lv_draw_buf_t*)res;
if (err || !db) { if (db) lv_draw_buf_destroy(db); return false; }
if (w != TILE_PX || h != TILE_PX) { lv_draw_buf_destroy(db); return false; }
for (int row = 0; row < TILE_PX; row++) {
const uint8_t* p = db->data + row * db->header.stride; // R, G, B, A
uint16_t* o = out + row * TILE_PX;
for (int i = 0; i < TILE_PX; i++, p += 4)
o[i] = (uint16_t)(((p[0] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[2] >> 3));
}
lv_draw_buf_destroy(db);
return true;
}
const char* attribution() const override { return _attr[0] ? _attr : "\xC2\xA9 OpenStreetMap contributors"; }
private:
const char* _root;
char _attr[96] = "";
static uint8_t* readRange(FILE* f, long off, uint32_t len) {
if (len == 0 || len > 512 * 1024) return nullptr;
uint8_t* buf = (uint8_t*)lv_malloc(len);
if (!buf) return nullptr;
if (fseek(f, off, SEEK_SET) != 0 || fread(buf, 1, len, f) != len) { lv_free(buf); return nullptr; }
return buf;
}
uint8_t* readLoose(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);
FILE* f = fopen(path, "rb");
if (!f) return nullptr;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
uint8_t* buf = sz > 0 ? readRange(f, 0, (uint32_t)sz) : nullptr;
fclose(f);
if (buf) len = (uint32_t)sz;
return buf;
}
uint8_t* readPacked(int z, int x, int y, uint32_t& len) {
char path[64];
snprintf(path, sizeof(path), "%s/%d/%d.pak", _root, z, x);
FILE* f = fopen(path, "rb");
if (!f) return nullptr;
uint8_t hdr[12];
uint32_t y0, y1, off[2];
uint8_t* buf = nullptr;
if (fread(hdr, 1, 12, f) == 12 && memcmp(hdr, "TPK1", 4) == 0) {
memcpy(&y0, hdr + 4, 4);
memcpy(&y1, hdr + 8, 4);
if (y >= (int)y0 && y <= (int)y1 &&
fseek(f, 12 + 4 * (y - (int)y0), SEEK_SET) == 0 && fread(off, 4, 2, f) == 2 && off[1] > off[0]) {
len = off[1] - off[0];
buf = readRange(f, off[0], len);
}
}
fclose(f);
return buf;
}
};
} // namespace mapview