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
+13 -2
View File
@@ -158,6 +158,7 @@ void UITask::loop() {
refreshNode();
}
}
if (_screen == SCR_MAP) mapLoop();
if (_screen == SCR_THREAD && (int32_t)(millis() - _next_thread_check_ms) >= 0) {
_next_thread_check_ms = millis() + 500;
uint32_t sig = threadSignature();
@@ -343,6 +344,8 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
_nearby_list = _nearby_status = _nearby_sort_lbl = _nearby_chips = nullptr;
_node_info = _node_ping = _node_delete_lbl = nullptr;
_scan_overlay = _scan_list = _scan_status = nullptr; // the popup went with the old screen
_map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = nullptr;
for (lv_obj_t*& t : _map_tiles) t = nullptr;
lv_obj_t* prev = lv_screen_active();
lv_obj_t* scr = lv_obj_create(NULL);
styleSurface(scr, theme::BG);
@@ -395,11 +398,13 @@ void UITask::back() {
if (_scan_overlay) closeScanPopup();
else showHome();
break;
case SCR_NODE: // back to where the node was picked: the list, or the scan popup over it
case SCR_NODE: // back to where the node was picked: map, the list, or the scan popup over it
if (_node_from_map) { showMap(); break; }
_screen = SCR_NEARBY;
buildNearby();
if (_node_from_scan) showScanPopup();
break;
case SCR_MAP: showHome(); break;
default: break;
}
}
@@ -410,6 +415,7 @@ static void onOpenChats(lv_event_t* e) { (void)e; s_ui->showChats(); }
static void onOpenSettings(lv_event_t* e) { (void)e; s_ui->showSettings(); }
static void onOpenNearby(lv_event_t* e) { (void)e; s_ui->showNearby(); }
static void onOpenMap(lv_event_t* e) { (void)e; s_ui->showMap(); }
// Home tile: icon over label, one of a row of three; optional amber value
// (unread count) in its top-right corner.
@@ -455,7 +461,8 @@ void UITask::buildHome() {
lv_obj_set_flex_flow(tiles, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(tiles, theme::GAP, 0);
homeTile(tiles, LV_SYMBOL_ENVELOPE, "Messages", onOpenChats, &_home_unread);
homeTile(tiles, LV_SYMBOL_GPS, "Nearby", onOpenNearby, NULL);
homeTile(tiles, UI_SYMBOL_USERS, "Nearby", onOpenNearby, NULL);
homeTile(tiles, UI_SYMBOL_MAP, "Map", onOpenMap, NULL);
homeTile(tiles, LV_SYMBOL_SETTINGS, "Settings", onOpenSettings, NULL);
}
@@ -804,6 +811,7 @@ void UITask::openScanNode(int row) {
if (row < 0 || row >= _scan->count()) return;
s_node = _scan->at(row);
_node_from_scan = true;
_node_from_map = false;
_scanning = false;
_pinging = false;
_delete_armed_ms = 0;
@@ -876,6 +884,7 @@ void UITask::openNode(int row) {
if (row < 0 || row >= _nearby->count()) return;
s_node = _nearby->at(row);
_node_from_scan = false;
_node_from_map = false;
_pinging = false;
_delete_armed_ms = 0;
_screen = SCR_NODE;
@@ -1044,6 +1053,8 @@ void UITask::nodeAction(uint8_t action) {
}
}
#include "MapScreen.h"
// ── Conversation ──────────────────────────────────────────────────────────────
static void onKeyboard(lv_event_t* e) {