Files
MeshTerm/meshterm/services/basemap.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

498 lines
24 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Fetch and cache OpenStreetMap vector tiles for the terminal map.
This is the only networked part of the map. It resolves a tile source's (versioned) tile-URL
template from its TileJSON, fetches ``.pbf`` vector tiles over HTTPS, and caches them on disk
so panning back over ground you've seen is instant and later sessions work offline. Decoding
is delegated to the pure :mod:`meshterm.core.mvt`, and narrowed to the layers the renderer
actually draws (``layers``) — a planet tile carries buildings, house numbers and POIs the
terminal map has no pixels for, and they are the bulk of its decode cost. Only the decode
narrows: whole tiles are still cached, so drawing more later costs a re-decode, never a
re-download.
Decoding is also written down. Beside the raw tiles sits a ``decoded/`` sidecar holding
the parsed layers (see :func:`~meshterm.core.mvt.dumps_layers`), so a tile is parsed once
ever rather than once per session — measured ~14x cheaper to reload on the PicoCalc. That
half of the cache is pure derived data: it is never what makes a tile "known", it carries
a stamp of the layer set it was decoded under so a narrowed blob is never served to a
caller wanting more, and it is the half that gets a size budget, because anything evicted
costs only the decode it was saving.
The default source is **OpenFreeMap** (openfreemap.org) — full-planet OpenStreetMap vector
tiles, free and requiring no API key. Everything here is best-effort: with no network and no
cached tiles the loader simply returns ``None`` and the map falls back to plotting nodes on a
blank grid, never raising into the UI. Best-effort, and **never given up on**: neither a
failed tile fetch nor a failed metadata resolve is remembered as a verdict on the source,
because the app can perfectly well open before the device it runs on has finished bringing
its network up (see :meth:`BasemapSource._resolve`).
**Only tiles that decode to real geometry are ever written to disk.** A cache is a memory,
and the one thing it must not remember is a lie: on a flaky link (the PicoCalc's Wi-Fi, most
of all) a timeout, a reset, or a body cut short is *silence*, not an empty tile, and storing
it leaves a blank square on the map for every future session. So a fetch is only cached once
its bytes have decoded to at least one feature; anything else is either dropped on the floor
(no answer — try again next pan) or remembered in memory for this session alone (the source
answered, and there is genuinely nothing there). Cache entries written by earlier builds that
can't decode are pruned on read, which heals a cache already poisoned this way.
Fetches are blocking (stdlib ``urllib``); callers on an event loop should run
:meth:`BasemapSource.load_tile` via ``asyncio.to_thread`` so the UI stays responsive.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
import urllib.error
import urllib.request
from collections import OrderedDict
from collections.abc import Container
from pathlib import Path
from typing import NamedTuple
from .. import __version__
from ..core.mvt import Layer, decode_tile, dumps_layers, loads_layers
#: OpenFreeMap planet TileJSON — its ``tiles`` array holds the current versioned template.
DEFAULT_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
#: Sent on every tile request. OpenFreeMap asks for no API key, so the user agent is the
#: only thing telling one client from another — which is how an operator reaches whoever is
#: costing them bandwidth, and why a stub URL or a version that has drifted is worse than
#: none at all. The version is the package's own :data:`~meshterm.__version__` rather than
#: an ``importlib.metadata`` lookup: MeshTerm is routinely run straight from a checkout
#: (this repo's venv, the PicoCalc's deploy) with no installed distribution to read, so the
#: metadata call would be the one that raises, and it would be a second version to keep in
#: step with the first. Built once at import because it never changes and cannot fail —
#: the tile path is no place to discover either.
_USER_AGENT = f"MeshTerm/{__version__} (+https://github.com/jpmartineau/MeshTerm; mesh node map)"
#: How long a tile the source gave no answer about is left alone before it is asked for
#: again. Long enough that a genuinely offline map isn't retrying every visible tile on a
#: loop, short enough that a Wi-Fi blip costs a few seconds of missing streets rather than
#: the rest of the session. It lives here, beside the source that failed to answer, because
#: both surfaces that draw tiles wait out the same cooldown (see
#: :meth:`meshterm.ui.map_screen.MapScreen._load` and the minimap's).
TILE_RETRY_SECONDS = 20.0
#: Fallback max tile zoom if the TileJSON doesn't declare one (OpenFreeMap serves 14).
_DEFAULT_MAX_ZOOM = 14
#: HTTP statuses that are the source *answering* "there is no such resource". Everything
#: else — 429, 5xx, and every transport error — is the absence of an answer.
_ABSENT_STATUSES = frozenset({404, 410})
#: Ceiling on the decoded sidecar cache. Decoded tiles run ~2.4x the size of the bytes
#: they came from, and unlike those bytes they can be rebuilt from what's already on disk,
#: so this half of the cache is the half that gets a budget.
_DEFAULT_MAX_DECODED_BYTES = 64 * 1024 * 1024
#: How much must be written before the sidecar budget is checked again. The check walks
#: the directory, which is slow on the SD card the PicoCalc runs from.
_PRUNE_AFTER_BYTES = 8 * 1024 * 1024
#: Decoded tiles held in RAM. A viewport spans at most a handful of tiles, so this covers
#: the view plus the ring a pan or a zoom step reaches into, and little more — the PicoCalc
#: has 100 MB of RAM in total and decoded layers are not small.
_MEMO_TILES = 24
#: How long a failed TileJSON resolve stands before the source will ask again. The PicoCalc
#: brings its Wi-Fi up some 40 seconds into the boot, well after the app it was started
#: alongside can reach the menu — so "offline" asked once at open is a verdict on the
#: *device's boot order*, not on the network, and latching it costs the whole session's
#: basemap. Long enough that a genuinely offline session isn't retrying into a void.
_RESOLVE_RETRY_SECONDS = 30.0
_log = logging.getLogger(__name__)
class _Response(NamedTuple):
"""One HTTP GET's outcome: whether the server answered, and what it said.
The distinction is the whole point. A definitive answer can be acted on — bytes to
decode, or a 404 meaning there is no tile at those coordinates. Offline, DNS failure,
timeout, connection reset, throttling, a server fault, a body short of its declared
length: none of those tell us anything about the tile, and must never be mistaken for
the source saying it is empty.
Attributes:
answered: Whether the server gave a definitive answer.
body: The body it gave; empty when the answer was "no such resource".
"""
answered: bool
body: bytes
class BasemapSource:
"""A cached OpenStreetMap vector-tile source.
Attributes:
cache_dir: Directory under which tiles and the resolved TileJSON are cached.
"""
def __init__(
self,
cache_dir: Path,
*,
tilejson_url: str = DEFAULT_TILEJSON_URL,
timeout: float = 12.0,
layers: Container[str] | None = None,
max_decoded_bytes: int = _DEFAULT_MAX_DECODED_BYTES,
) -> None:
"""Open a tile source backed by an on-disk cache.
Args:
cache_dir: Where fetched tiles and TileJSON metadata are stored.
tilejson_url: URL of the source's TileJSON document.
timeout: Per-request network timeout in seconds.
layers: Restrict decoding to these layer names (the renderer passes
:data:`meshterm.ui.map_render.DRAWN_LAYERS`). Only the *decode* narrows —
the cache still stores whole tiles — so this is a pure CPU saving that a
later renderer can widen without re-fetching anything.
max_decoded_bytes: Ceiling on the decoded sidecar cache, which is derived data
and so is the one part of the cache that can be thrown away freely.
"""
self.cache_dir = cache_dir
self._tilejson_url = tilejson_url
self._timeout = timeout
self._layers = layers
self._max_decoded_bytes = max_decoded_bytes
self._decoded_written = 0
# What a sidecar was decoded *under*. A blob written for one layer set must never
# be served to a caller expecting another, so the set travels with the bytes.
self._stamp = "all" if layers is None else ",".join(sorted(layers)) # type: ignore[arg-type]
self._template: str | None = None
self._max_zoom: int | None = None
# When the source may next try to resolve its template. Zero means "now";
# a success sets the template and this is never consulted again.
self._resolve_after = 0.0
# Tiles the source answered "nothing here" for this session. Held in memory rather
# than on disk so the claim expires with the process: a blank tile is cheap to
# re-ask about, and a stale one on disk is a permanent hole in the map.
self._blank: set[tuple[int, int, int]] = set()
# Decoded layers kept in RAM, most-recently-used last. The sidecar already spares
# the protobuf decode, but a marshal load off the SD card is still ~96 ms on the
# PicoCalc — and a map paints its whole viewport's worth of tiles on *every*
# frame, so panning one dot re-read every tile that had not moved. Bounded because
# decoded layers are the fattest thing this class holds.
self._memo: OrderedDict[tuple[int, int, int], list[Layer]] = OrderedDict()
# -- metadata ---------------------------------------------------------------
def _tilejson_path(self) -> Path:
return self.cache_dir / "tilejson.json"
def _resolve(self) -> None:
"""Resolve and cache the tile-URL template and max zoom (best-effort, retried).
Latched on **success only**. A failed resolve is the absence of an answer, exactly
as a failed tile fetch is (see :class:`_Response`), and remembering it as "this
source is offline" is the same lie in a costlier place: it is asked once, seconds
after the app opens, and its verdict then governs every tile for the rest of the
session. On the PicoCalc the Wi-Fi associates some forty seconds into the boot, so
an app started with it answers that question before the answer can be true and
draws the whole session's map from whatever was already on disk — which is exactly
a scatter of black tiles at the zooms whose ground the cache happens not to hold.
So a failure only stands for :data:`_RESOLVE_RETRY_SECONDS`, and the next caller
past that asks again. The deadline is claimed *before* the round-trip, so the
several worker threads a map frame puts through here don't all make the same call.
Blocking, and called from worker threads (a tile fetch, the menu's warm) — never
from the paint path, which reads :attr:`available` instead.
"""
if self._template is not None:
return
now = time.monotonic()
if now < self._resolve_after:
return
self._resolve_after = now + _RESOLVE_RETRY_SECONDS
# Prefer a freshly fetched TileJSON (the template is versioned and rotates), but fall
# back to a previously cached copy so a session started offline can still use disk
# tiles and even re-fetch if the template is still valid.
resp = self._http_get(self._tilejson_url)
data = resp.body if resp.answered and resp.body else None
if data is not None:
try:
self._tilejson_path().parent.mkdir(parents=True, exist_ok=True)
self._tilejson_path().write_bytes(data)
except OSError: # pragma: no cover - cache write failure is non-fatal
pass
if data is None and self._tilejson_path().exists():
try:
data = self._tilejson_path().read_bytes()
except OSError: # pragma: no cover
data = None
if data is None:
return
try:
doc = json.loads(data)
tiles = doc.get("tiles") or []
if tiles:
self._template = str(tiles[0])
self._max_zoom = int(doc.get("maxzoom", _DEFAULT_MAX_ZOOM))
except (ValueError, TypeError): # pragma: no cover - malformed TileJSON
pass
@property
def max_zoom(self) -> int:
"""The highest zoom the source serves (resolved lazily; sensible default offline)."""
self._resolve()
return self._max_zoom if self._max_zoom is not None else _DEFAULT_MAX_ZOOM
@property
def available(self) -> bool:
"""Whether a tile-URL template is already known — asking nothing to find out.
Read on the paint path (the map's title, the mini-map's caption), so it must never
be the thing that goes to the network: a resolve is a blocking round-trip, and one
answered from a repaint would stall the UI for its whole timeout. Touching
:attr:`max_zoom` — which every map surface does, off the loop, before it opens —
is what resolves; a tile fetch resolves too, in its own thread. This only reports.
False therefore means "no template *yet*", not "give up": a caller that skips its
fetches on it will never let one happen (see
:meth:`meshterm.ui.map_screen.MapScreen._ensure_tiles`, which asks regardless).
"""
return self._template is not None
def answered_empty(self, z: int, x: int, y: int) -> bool:
"""Whether the source *answered* that there is no tile at these coordinates.
The one way to tell the two ``None`` returns of :meth:`load_tile` apart: a tile the
source served as absent (a 404, or bytes holding no layer at all) will never
become anything else, while a tile we simply got no answer about is worth asking
again. Callers that cache the ``None`` need the difference — see
:meth:`meshterm.ui.map_screen.MapScreen._load`.
"""
return (z, x, y) in self._blank
# -- tiles ------------------------------------------------------------------
def _tile_path(self, z: int, x: int, y: int) -> Path:
return self.cache_dir / "tiles" / str(z) / str(x) / f"{y}.pbf"
def _decoded_path(self, z: int, x: int, y: int) -> Path:
return self.cache_dir / "decoded" / str(z) / str(x) / f"{y}.bin"
def load_tile(self, z: int, x: int, y: int) -> list[Layer] | None:
"""Return the decoded layers for a tile, from cache or the network.
Three places are tried in cost order: the decoded sidecar (a ``marshal`` load),
the raw ``.pbf`` (a full protobuf decode), then the network. The sidecar is pure
derived data — it is never the reason a tile is considered known, and losing it
costs only the decode it was there to save.
The raw cache only ever holds tiles that decoded to real geometry. A tile the
source never answered for is left uncached, so the next pan over it asks again
instead of drawing a permanent blank; a cached file that no longer decodes is
pruned for the same reason.
Args:
z: Tile zoom.
x: Tile x index.
y: Tile y index.
Returns:
The decoded layers, or ``None`` if the tile is unavailable (offline and
uncached, or a genuinely empty/missing tile).
"""
key = (z, x, y)
if key in self._blank:
return None
hot = self._memo.get(key)
if hot is not None:
self._memo.move_to_end(key)
return hot
ready = self._read_decoded(z, x, y)
if ready is not None:
self._remember(key, ready)
return ready
path = self._tile_path(z, x, y)
cached = self._read_cached(path)
if cached is not None:
layers = self._decode(cached, key)
if layers is not None:
self._write_decoded(z, x, y, layers)
self._remember(key, layers)
return layers
# Nothing drawable came out: a zero-byte marker from a build that cached
# network failures, or bytes truncated by a link (or a power cut) mid-write.
# Either way it is a blank square for ever unless we drop it and re-ask.
_log.debug("dropping unusable cached tile %s/%s/%s", z, x, y)
self._discard_cached(path)
raw = self._fetch_tile(z, x, y)
if raw is None:
return None # no answer — nothing learned, so nothing is written down
layers = self._decode(raw, key)
if layers is None:
self._blank.add(key) # the source answered: there is genuinely nothing here
return None
self._write_cached(path, raw)
self._write_decoded(z, x, y, layers)
self._remember(key, layers)
return layers
def _remember(self, key: tuple[int, int, int], layers: list[Layer]) -> None:
"""Hold a decoded tile in RAM, evicting the least recently used past the budget."""
self._memo[key] = layers
self._memo.move_to_end(key)
while len(self._memo) > _MEMO_TILES:
self._memo.popitem(last=False)
# -- the decoded sidecar ----------------------------------------------------
def _read_decoded(self, z: int, x: int, y: int) -> list[Layer] | None:
"""Return a tile's already-decoded layers, or ``None`` to decode it properly."""
blob = self._read_cached(self._decoded_path(z, x, y))
return None if blob is None else loads_layers(blob, stamp=self._stamp)
def _write_decoded(self, z: int, x: int, y: int, layers: list[Layer]) -> None:
"""Write a tile's decoded layers beside the raw bytes, and keep the dir bounded."""
try:
blob = dumps_layers(layers, stamp=self._stamp)
except ValueError: # pragma: no cover - a tag type marshal can't represent
return
self._write_cached(self._decoded_path(z, x, y), blob)
# Sweeping the tree costs a directory walk on an SD card, so amortise it over a
# good many writes rather than checking the budget on every tile.
self._decoded_written += len(blob)
if self._decoded_written >= _PRUNE_AFTER_BYTES:
self._decoded_written = 0
self._prune_decoded()
def _prune_decoded(self) -> None:
"""Drop the least recently written sidecars until the budget is met.
Decoded tiles run ~2.4x the size of the bytes they came from, and the raw cache is
already unbounded, so this side of it gets a ceiling. Eviction is by modification
time, which on a write-once file is also the order it was first needed in.
"""
root = self.cache_dir / "decoded"
try:
files = [(p.stat().st_mtime, p.stat().st_size, p) for p in root.rglob("*.bin")]
except OSError: # pragma: no cover - a cache we can't walk is still readable
return
total = sum(size for _, size, _ in files)
if total <= self._max_decoded_bytes:
return
for _, size, p in sorted(files):
if total <= self._max_decoded_bytes:
break
self._discard_cached(p)
total -= size
_log.debug("pruned decoded tile cache to %.1f MB", total / 1024 / 1024)
def _decode(self, raw: bytes, key: tuple[int, int, int]) -> list[Layer] | None:
"""Decode a tile's bytes, or ``None`` if they aren't a tile at all.
The question this answers is "are these real tile bytes?", and the answer decides
whether a cache entry is kept or pruned — so it must not be confused with "is
there anything here I feel like drawing". Under ``layers`` most of a tile's
content is deliberately left undecoded, and a tile whose every layer we skip
would otherwise read as blank and get its (perfectly good) cache entry deleted
and re-downloaded every session.
So the test is whether the bytes parsed into any layer at all. That is sound
because the three ways a tile is *not* real are all distinguishable without
looking at features: empty bytes decode to no layers, and truncated or junk bytes
raise out of the parser rather than yielding a well-formed featureless layer.
Args:
raw: The tile's raw ``.pbf`` bytes.
key: The ``(z, x, y)`` the bytes claim to be, for the log line.
Returns:
The decoded layers, or ``None`` for empty or corrupt bytes.
"""
try:
layers = decode_tile(raw, layers=self._layers)
except Exception as exc: # noqa: BLE001 - a corrupt tile must not crash the map
_log.debug("failed to decode tile %s/%s/%s: %s", *key, exc)
return None
return layers or None
def _fetch_tile(self, z: int, x: int, y: int) -> bytes | None:
"""Fetch a tile's raw bytes from the network.
Args:
z: Tile zoom.
x: Tile x index.
y: Tile y index.
Returns:
The body the source gave — possibly empty, meaning "no tile at those
coordinates" — or ``None`` if it never answered (offline, timed out,
throttled, faulted, or cut short).
"""
self._resolve()
if self._template is None:
return None
url = self._template.replace("{z}", str(z)).replace("{x}", str(x)).replace("{y}", str(y))
resp = self._http_get(url)
return resp.body if resp.answered else None
@staticmethod
def _read_cached(path: Path) -> bytes | None:
try:
return path.read_bytes() if path.exists() else None
except OSError: # pragma: no cover
return None
@staticmethod
def _write_cached(path: Path, raw: bytes) -> None:
"""Write a tile to the cache atomically, so a half-written one is never read back."""
# Unique per writer: the map screen and a mini-map share one source, and both may
# be fetching the same tile in their own worker threads.
tmp = path.with_name(f"{path.name}.{os.getpid()}-{threading.get_ident()}.part")
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp.write_bytes(raw)
os.replace(tmp, path)
except OSError: # pragma: no cover - cache write failure is non-fatal
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
@staticmethod
def _discard_cached(path: Path) -> None:
"""Drop a cache entry that proved unusable, so the tile can be fetched afresh."""
try:
path.unlink(missing_ok=True)
except OSError: # pragma: no cover - a cache we can't prune is still readable
pass
def _http_get(self, url: str) -> _Response:
"""GET a URL, telling a definitive answer apart from no answer at all.
Args:
url: The absolute URL to fetch.
Returns:
The outcome — see :class:`_Response`.
"""
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
body = resp.read()
declared = (resp.headers.get("Content-Length") or "").strip()
# A flaky link can end a read early without raising. A body short of its
# declared length is a truncation, not a tile — and not an empty one.
if declared.isdigit() and len(body) != int(declared):
_log.debug("short read for %s: %d of %s bytes", url, len(body), declared)
return _Response(False, b"")
return _Response(True, body)
except urllib.error.HTTPError as exc:
answered = exc.code in _ABSENT_STATUSES
if not answered:
_log.debug("fetch failed for %s: HTTP %s", url, exc.code)
return _Response(answered, b"")
except Exception as exc: # noqa: BLE001 - offline / timeout / reset are "no answer"
_log.debug("fetch failed for %s: %s", url, exc)
return _Response(False, b"")