Files
wadamesh/deploy/tile-transcode.py
Kaj Schittecat f6192aa6e5 touch+deploy: opt-in OpenTopoMap style + persist map link-lines (open PRs)
Implements the three open PRs, with OpenStreetMap kept as the shipping default and
OpenTopoMap added as a user toggle (the deploy PRs flipped the default to topo —
this keeps OSM default and makes topo opt-in).

PR #61 (@Yazutsu) — persist the map 'Show link lines' toggle across reboots:
TouchCfg gains map_show_links (cfg v20, default 1); saved in mapOptLinesCb, loaded
in begin().

PR #52/#53 (@samuelcoustet) — OpenTopoMap tile proxy, adapted:
- deploy: tiles.wadamesh.com.conf keeps the default /{z}/{x}/{y}.jpg on OSM and
  adds /opentopo/ (OpenTopoMap) + an explicit /osm/ alias; tile-transcode.py grows
  _fetch_tile_png/_tile_response_from_png helpers feeding /osm + /opentopo while the
  root route stays OSM.
- firmware: new map_style pref (cfg v21, 0=OSM default, 1=OpenTopoMap) + a
  'Topographic map' switch in Map → Options. Topo tiles are namespaced on disk
  (/tiles/topo) and fetched via the proxy's /opentopo route, so the OSM and topo
  caches never collide and toggling is instant for already-cached tiles. Topo
  bypasses the OSM-only microSD packs and always fetches online; the shared
  (z,x,y) fetch-dedup ring is cleared on toggle so the new style re-queues.

Legal: OpenTopoMap map tiles are © OpenTopoMap (CC-BY-SA) over © OpenStreetMap
contributors (ODbL) + SRTM. The on-map © attribution and the Options → Info credits
sheet switch to that text when topo is active; the proxy keeps a contactable
User-Agent + 14-day cache per OpenTopoMap's tile-usage policy.

Topo needs the VPS deploy (restart wadamesh-tile-transcode + reload nginx) before
the /opentopo route serves; until then topo tiles 404 (handled as a permanent miss,
no retry hammer). OSM default is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Yazutsu <andrzej@gruziel.pl>
2026-06-26 18:49:41 +02:00

199 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
wadamesh tile transcode service.
Why this exists
---------------
The touch firmware fetches map tiles over plain HTTP (on-device HTTPS is not
workable — mbedTLS needs ~30 KB of internal heap for a handshake and only
~5 KB survives Wi-Fi association). It also can only decode JPEG cheaply: the
device's SJPG/TJpgDec path decodes straight to RGB565 in small stripes, while
PNG decode (lodepng) needs a full 256 KB ARGB8888 buffer per tile and bogged
the UI down / rendered as noise.
OpenStreetMap only serves PNG. So this service sits between nginx and OSM:
it fetches the PNG from OSM (HTTPS, with the policy-required identifying
User-Agent) and re-encodes it as JPEG, which is what the device asks for.
nginx (see tiles.wadamesh.com.conf) reverse-proxies tiles.wadamesh.com
to this service on 127.0.0.1:5005 and caches the JPEG result on disk, so OSM
is only hit on a cache miss and transcoding only happens once per tile.
Run
---
pip install flask pillow requests
python3 tile-transcode.py # listens on 127.0.0.1:5005
Production: use the systemd unit (wadamesh-tile-transcode.service).
"""
import io
import sys
import time
try:
import requests
from flask import Flask, Response, abort, request
from PIL import Image
except ImportError:
sys.exit("ERROR: pip install flask pillow requests")
OSM_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
# OpenTopoMap (topographic / terrain-relief style) — an OPT-IN alternate style.
# The firmware default stays OpenStreetMap; the device only requests /opentopo/
# when the user enables it. Like OSM it serves PNG, so it goes through the same
# fetch+transcode path. Legal: map style © OpenTopoMap (CC-BY-SA), underlying
# data © OpenStreetMap contributors (ODbL) + SRTM — the touch UI shows that
# attribution whenever topo is the selected style. OpenTopoMap's tile-usage
# policy asks for a contactable User-Agent + caching; nginx caches results 14
# days so each unique tile hits OpenTopoMap at most once per fortnight.
OPENTOPO_URL = "https://tile.opentopomap.org/{z}/{x}/{y}.png"
# Tile policy: identify yourself with a contactable UA. This is what the upstream
# tile servers see — the device never talks to them directly.
OSM_UA = "wadamesh-tile-proxy/1.0 (+https://wadamesh.com)"
JPEG_QUALITY = 80 # slippy tiles compress well; 80 ≈ visually lossless
MAX_TILE_BYTES = 256 * 1024
REQUEST_TIMEOUT = 12 # seconds for the OSM fetch
# Elevation backend for the line-of-sight analyzer. opentopodata's public
# SRTM 30 m dataset: max 100 locations/request, ~1 req/sec, 1000/day. Fine
# for a personal device. Self-host opentopodata if you outgrow the cap.
ELEV_URL = "https://api.opentopodata.org/v1/srtm30m"
ELEV_MAX_POINTS = 100
app = Flask(__name__)
_session = requests.Session()
_session.headers.update({"User-Agent": OSM_UA, "Accept": "image/png,image/*"})
def _fetch_tile_png(upstream_url: str, z: int, x: int, y: int) -> bytes:
# Range-check before touching the upstream so we can't be turned into an open proxy.
if not (0 <= z <= 19):
abort(404)
n = 1 << z
if not (0 <= x < n and 0 <= y < n):
abort(404)
try:
r = _session.get(upstream_url.format(z=z, x=x, y=y), timeout=REQUEST_TIMEOUT,
stream=True)
except requests.RequestException:
abort(502)
if r.status_code != 200:
# Pass the upstream status through (404 = empty/sea tile — nginx caches it).
abort(r.status_code if r.status_code in (404, 429) else 502)
raw = r.raw.read(MAX_TILE_BYTES + 1, decode_content=True)
if len(raw) > MAX_TILE_BYTES:
abort(502)
return raw
def _tile_response_from_png(raw: bytes) -> Response:
try:
img = Image.open(io.BytesIO(raw)).convert("RGB")
out = io.BytesIO()
img.save(out, "JPEG", quality=JPEG_QUALITY, optimize=True)
except Exception:
abort(502)
return Response(out.getvalue(), mimetype="image/jpeg",
headers={"Cache-Control": "public, max-age=2592000"})
# Default path the firmware uses → OpenStreetMap (the shipping default style).
@app.get("/<int:z>/<int:x>/<int:y>.jpg")
def tile(z: int, x: int, y: int):
return _tile_response_from_png(_fetch_tile_png(OSM_URL, z, x, y))
# Explicit OpenStreetMap alias (identical to the root path).
@app.get("/osm/<int:z>/<int:x>/<int:y>.jpg")
def tile_osm(z: int, x: int, y: int):
return _tile_response_from_png(_fetch_tile_png(OSM_URL, z, x, y))
# OpenTopoMap (topographic) — opt-in alternate style selected on the device.
@app.get("/opentopo/<int:z>/<int:x>/<int:y>.jpg")
def tile_opentopo(z: int, x: int, y: int):
return _tile_response_from_png(_fetch_tile_png(OPENTOPO_URL, z, x, y))
@app.get("/elev")
def elev():
"""Elevation profile for the line-of-sight analyzer.
Request: GET /elev?locations=lat,lon|lat,lon|... (up to 100 points)
Response: compact CSV of integer metres, one per point, in order:
"12,15,40,38,..." ('null' for any point with no data).
The device computes the great-circle sample points itself; this just
proxies them to the SRTM backend and strips the JSON down to a tiny
body the firmware can parse without a JSON library.
"""
locations = request.args.get("locations", "").strip()
if not locations:
abort(400)
pts = locations.split("|")
if len(pts) < 2 or len(pts) > ELEV_MAX_POINTS:
abort(400)
# Validate each "lat,lon" before forwarding so we can't be used to hit
# arbitrary query strings upstream.
for p in pts:
try:
lat_s, lon_s = p.split(",")
lat, lon = float(lat_s), float(lon_s)
except ValueError:
abort(400)
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
abort(400)
# opentopodata rate-limits to ~1 req/sec and occasionally 5xx's. Retry a
# couple of times with a >1 s gap so a transient limit/blip doesn't bubble
# up to the device as a failure.
# 2 attempts max so the proxy's worst case (~2 × (timeout + 1.2 s) ≈ 26 s)
# stays under the device's read timeout (30 s) — otherwise the device
# gives up mid-retry and the work is wasted.
r = None
for attempt in range(2):
if attempt:
time.sleep(1.2)
try:
r = _session.get(ELEV_URL, params={"locations": locations},
timeout=REQUEST_TIMEOUT)
except requests.RequestException:
r = None
continue
if r.status_code == 200:
break
if r.status_code in (429, 500, 502, 503, 504):
continue # transient — retry
break # other 4xx — don't bother retrying
if r is None:
abort(502)
if r.status_code != 200:
abort(502 if r.status_code >= 500 or r.status_code == 429 else 400)
try:
data = r.json()
except ValueError:
abort(502)
if data.get("status") != "OK":
abort(502)
out = []
for res in data.get("results", []):
e = res.get("elevation")
out.append(str(int(round(e))) if e is not None else "null")
if not out:
abort(502)
return Response(",".join(out), mimetype="text/plain",
headers={"Cache-Control": "public, max-age=2592000"})
@app.get("/healthz")
def healthz():
return "ok\n", 200
if __name__ == "__main__":
# threaded=True so concurrent device fetches don't serialize behind one
# another. Bind to localhost only — nginx is the public face.
app.run(host="127.0.0.1", port=5005, threaded=True)