Files
MeshCore-Solo/tools/maps/fetch_tiles.py
T
JakubandClaude Opus 5.5 64321bf12b 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>
2026-09-25 10:57:46 +02:00

153 lines
6.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Offline raster map tiles for the ui-lvgl map screen (Wio Tracker L2 SD card).
Writes <out>/maps/{z}/{x}/{y}.png -- the same layout Meshtastic MUI uses, so a
card prepared for either works for both. Copy the `maps` folder to the root of
the SD card. The device can also download an area itself over WiFi; this is
the offline / bulk option.
tools/maps/fetch_tiles.py --preset krakow --url URL --out /Volumes/SDCARD
tools/maps/fetch_tiles.py --bbox 19.90,50.04,19.98,50.08 --zoom 10-16 --url URL --out ./card
tools/maps/fetch_tiles.py --preset krakow --dry-run # count + size estimate
Tile source: --url (or TILE_URL) with {z}/{x}/{y}. There is deliberately no
default. Check the provider's terms before downloading an area:
- tile.openstreetmap.org forbids offline use and answers with an
"access blocked" image;
- OpenTopoMap (https://a.tile.opentopomap.org/{z}/{x}/{y}.png) tolerates
moderate use -- a town or a trip, not a country;
- keyed providers (MapTiler, Thunderforest, Stadia, ...) put the key in the
URL; their plans say whether offline caching is allowed;
- your own render server (e.g. from an OSM extract) has no limits.
--attribution is written to maps/attribution.txt; the device shows it on the
map, as the data licences require.
The tool stops if the server answers every tile with the same image (a
"blocked" or "API key required" placeholder).
Resumable (existing files are skipped), single connection, throttled.
--manifest also writes maps/manifest.json (the list of tiles), which the
browser simulator (variants/sim/web/lvgl.html) uses to preload them.
"""
import argparse
import json
import math
import os
import sys
import time
import urllib.error
import urllib.request
PRESETS = {
# name: (lon_min, lat_min, lon_max, lat_max, z_min, z_max)
"krakow": (19.90, 50.04, 19.98, 50.08, 10, 16), # Old Town + Kazimierz; test set
"krakow-wide": (19.79, 49.97, 20.22, 50.13, 10, 15),
"tatry": (19.75, 49.15, 20.25, 49.32, 10, 15),
"warszawa": (20.85, 52.10, 21.20, 52.35, 10, 15),
"poland": (14.07, 49.00, 24.15, 54.84, 5, 9), # overview zooms only
}
USER_AGENT = "MeshCore-Solo-map-prep/1.0 (offline tiles for a LoRa handheld; throttled)"
def tile_xy(lon, lat, z):
n = 1 << z
x = int((lon + 180.0) / 360.0 * n)
lat_r = math.radians(max(min(lat, 85.0511), -85.0511))
y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n)
return min(max(x, 0), n - 1), min(max(y, 0), n - 1)
def tiles_for(bbox, z_min, z_max):
lon_min, lat_min, lon_max, lat_max = bbox
for z in range(z_min, z_max + 1):
x0, y0 = tile_xy(lon_min, lat_max, z) # north-west corner
x1, y1 = tile_xy(lon_max, lat_min, z) # south-east corner
for x in range(x0, x1 + 1):
for y in range(y0, y1 + 1):
yield z, x, y
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--preset", choices=sorted(PRESETS))
ap.add_argument("--bbox", help="lon_min,lat_min,lon_max,lat_max")
ap.add_argument("--zoom", help="z_min-z_max (default from preset, else 10-15)")
ap.add_argument("--out", default=".", help="folder that gets maps/ (e.g. the SD card root)")
ap.add_argument("--url", default=os.environ.get("TILE_URL"), help="tile URL template with {z} {x} {y}")
ap.add_argument("--attribution", default="\u00a9 OpenStreetMap contributors",
help="credit line shown on the device's map")
ap.add_argument("--delay", type=float, default=1.0, help="seconds between requests (default 1)")
ap.add_argument("--manifest", action="store_true", help="also write maps/manifest.json")
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
if a.preset:
*bbox, z_min, z_max = PRESETS[a.preset]
elif a.bbox:
bbox = [float(v) for v in a.bbox.split(",")]
z_min, z_max = 10, 15
else:
ap.error("give --preset or --bbox")
if a.zoom:
z_min, z_max = (int(v) for v in a.zoom.split("-"))
tiles = list(tiles_for(bbox, z_min, z_max))
print(f"{len(tiles)} tiles, z{z_min}-{z_max}, ~{len(tiles) * 25 // 1024} MB (at ~25 KB/tile)")
if a.dry_run:
return
if not a.url:
ap.error("give --url (see the provider notes in --help)")
root = os.path.join(a.out, "maps")
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "attribution.txt"), "w", encoding="utf-8") as f:
f.write(a.attribution + "\n")
got = skipped = failed = 0
seen = {} # payload -> count, over the first downloads: a placeholder repeats
for i, (z, x, y) in enumerate(tiles, 1):
path = os.path.join(root, str(z), str(x), f"{y}.png")
if os.path.exists(path) and os.path.getsize(path) > 0:
skipped += 1
continue
os.makedirs(os.path.dirname(path), exist_ok=True)
url = a.url.format(z=z, x=x, y=y)
for attempt in range(3):
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=20) as r:
data = r.read()
if got < 8:
seen[data] = seen.get(data, 0) + 1
if seen[data] >= 4:
sys.exit("stopped: the server returns the same image for different tiles "
"(blocked or API key required?) -- check --url; delete what was written")
with open(path + ".tmp", "wb") as f:
f.write(data)
os.replace(path + ".tmp", path)
got += 1
break
except (urllib.error.URLError, TimeoutError) as e:
if attempt == 2:
failed += 1
print(f" failed {z}/{x}/{y}: {e}", file=sys.stderr)
time.sleep(2 * (attempt + 1))
if i % 50 == 0:
print(f" {i}/{len(tiles)}")
time.sleep(a.delay)
if a.manifest:
listed = []
for dirpath, _, files in os.walk(root):
for fn in files:
if fn.endswith(".png") or fn == "attribution.txt":
listed.append(os.path.relpath(os.path.join(dirpath, fn), root).replace(os.sep, "/"))
with open(os.path.join(root, "manifest.json"), "w") as f:
json.dump(sorted(listed), f)
print(f"done: {got} downloaded, {skipped} already there, {failed} failed -> {root}")
if __name__ == "__main__":
main()