mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-06-26 22:21:40 +00:00
f6192aa6e5
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>
199 lines
7.5 KiB
Python
199 lines
7.5 KiB
Python
#!/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)
|