Files
yaris-obd/scripts/download_map_tiles.py
Mathieu MoranandCopilot 261ef3f4db Revive yaris-obd: USB OBD adapter, real GPS/GNSS, offline map, hotspot, SQLite logging, dashcam retrieval
Squashed rewrite of the project revival work (replaces prior history on this
branch to remove personal data that had been accidentally committed - real
LAN IP, GPS coordinates, home WiFi SSID, and phone model). See PLAN.md for
full phase-by-phase details. Summary:

- Phase 2: adapt app_obd.py for a USB OBD-II adapter (was Bluetooth-only),
  auto-detect /dev/ttyUSB*, fix several pre-existing crash bugs.
- Phase 3/4: make the web UI work fully offline (vendored Socket.IO/Leaflet/
  Chart.js/FontAwesome, offline map tile downloader script, live GPS marker
  tracking fix).
- Phase 5: systemd deployment/autostart on the Pi.
- Phase 6: WiFi hotspot mode via NetworkManager (auto-switch between home
  WiFi and a local AP for in-car web UI access), mDNS hostname + port 80
  redirect so the UI is reachable at a plain hostname with no port number.
- Phase 7: fix app_db.py/app_sql.py bugs, wire up local SQLite trip logging.
- Phase 8: brand-agnostic USB dashcam file retrieval (lsblk transport-based
  detection, works with any dashcam brand/folder layout).
- Rewritten readme.md with a proper project overview.

Co-authored-by: Copilot <[email protected]>
2026-08-08 18:01:35 -04:00

133 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""
One-time offline map tile downloader (Phase 3).
Downloads raster XYZ tiles from MapTiler Cloud for a bounding box covering
your usual driving area and saves them under static/vendor/tiles/{z}/{x}/{y}.png
so the web UI's Leaflet map works with zero internet access in the car.
This is a DEV-MACHINE-ONLY script. It needs internet + a free MapTiler API
key. The output (static PNG tiles) is what actually ships to the Pi -
the API key itself is never stored in the repo or deployed anywhere.
Get a free key: https://cloud.maptiler.com/account/keys/ (no credit card
required for the free tier, good for ~100,000 tile requests/month).
Usage:
export MAPTILER_KEY=your_key_here
python3 scripts/download_map_tiles.py \
--min-lat 40.00 --max-lat 41.00 --min-lon -75.00 --max-lon -74.00
# (bounding box is required - use your own driving area's coordinates,
# e.g. from https://boundingbox.klokantech.com/)
# optionally override the zoom range too:
# --min-zoom 8 --max-zoom 15
Re-running is safe/cheap: already-downloaded tiles are skipped.
"""
import argparse
import math
import os
import sys
import time
import urllib.request
import urllib.error
# zoom 15 gives good street-level detail; zoom 16 roughly quadruples tile
# count/quota usage on top of that, so it's opt-in via --max-zoom 16.
DEFAULT_MIN_ZOOM = 8
DEFAULT_MAX_ZOOM = 15
MAP_ID = "streets-v2"
TILE_SIZE = 256
OUTPUT_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "static", "vendor", "tiles"
)
def deg2num(lat, lon, zoom):
lat_rad = math.radians(lat)
n = 2.0 ** zoom
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return x, y
def iter_tiles(min_lat, max_lat, min_lon, max_lon, min_zoom, max_zoom):
for zoom in range(min_zoom, max_zoom + 1):
x1, y1 = deg2num(max_lat, min_lon, zoom)
x2, y2 = deg2num(min_lat, max_lon, zoom)
xlo, xhi = sorted((x1, x2))
ylo, yhi = sorted((y1, y2))
for x in range(xlo, xhi + 1):
for y in range(ylo, yhi + 1):
yield zoom, x, y
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--min-lat", type=float, required=True, help="Bounding box min latitude (your own driving area)")
parser.add_argument("--max-lat", type=float, required=True, help="Bounding box max latitude")
parser.add_argument("--min-lon", type=float, required=True, help="Bounding box min longitude")
parser.add_argument("--max-lon", type=float, required=True, help="Bounding box max longitude")
parser.add_argument("--min-zoom", type=int, default=DEFAULT_MIN_ZOOM)
parser.add_argument("--max-zoom", type=int, default=DEFAULT_MAX_ZOOM)
parser.add_argument("--api-key", default=os.environ.get("MAPTILER_KEY"),
help="MapTiler API key (defaults to MAPTILER_KEY env var)")
parser.add_argument("--delay", type=float, default=0.05,
help="Seconds to sleep between requests (be nice to the API)")
args = parser.parse_args()
if not args.api_key:
print("ERROR: no MapTiler API key given. Set MAPTILER_KEY env var or pass --api-key.",
file=sys.stderr)
print("Get a free key at https://cloud.maptiler.com/account/keys/", file=sys.stderr)
return 1
tiles = list(iter_tiles(args.min_lat, args.max_lat, args.min_lon, args.max_lon,
args.min_zoom, args.max_zoom))
total = len(tiles)
print(f"Planning to fetch up to {total} tiles (zoom {args.min_zoom}-{args.max_zoom}) "
f"into {os.path.abspath(OUTPUT_DIR)}")
downloaded = 0
skipped = 0
failed = 0
for i, (z, x, y) in enumerate(tiles, 1):
out_dir = os.path.join(OUTPUT_DIR, str(z), str(x))
out_path = os.path.join(out_dir, f"{y}.png")
if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
skipped += 1
continue
os.makedirs(out_dir, exist_ok=True)
url = f"https://api.maptiler.com/maps/{MAP_ID}/{TILE_SIZE}/{z}/{x}/{y}.png?key={args.api_key}"
try:
with urllib.request.urlopen(url, timeout=15) as resp:
data = resp.read()
with open(out_path, "wb") as f:
f.write(data)
downloaded += 1
except urllib.error.HTTPError as e:
if e.code == 400:
# Out of bounds tile (no data for this x/y/z combo) - skip silently.
skipped += 1
else:
failed += 1
print(f" [{i}/{total}] FAILED z={z} x={x} y={y}: {e}", file=sys.stderr)
except Exception as e:
failed += 1
print(f" [{i}/{total}] FAILED z={z} x={x} y={y}: {e}", file=sys.stderr)
if i % 200 == 0 or i == total:
print(f" [{i}/{total}] downloaded={downloaded} skipped={skipped} failed={failed}")
time.sleep(args.delay)
print(f"Done. downloaded={downloaded} skipped(existing/out-of-bounds)={skipped} failed={failed}")
return 0 if failed == 0 else 2
if __name__ == "__main__":
sys.exit(main())