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]>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user