import argparse import logging import math import sqlite3 import sys import time from datetime import UTC, datetime from pathlib import Path from flight_map.geo import DEFAULT_BOUNDS, Bounds from flight_map.storage import create_session, database, get_session, initialize, purge_session def positive(value: str) -> float: try: result = float(value) except ValueError as exc: raise argparse.ArgumentTypeError("Expected a positive finite number.") from exc if not math.isfinite(result) or result <= 0: raise argparse.ArgumentTypeError("Expected a positive finite number.") return result def timestamp(value: str) -> float: try: parsed = datetime.fromisoformat(value) except ValueError as exc: raise argparse.ArgumentTypeError( "Use an ISO 8601 timestamp, e.g. 2026-10-20T12:00:00Z." ) from exc if parsed.tzinfo is None: raise argparse.ArgumentTypeError("Timestamps must include a time zone, such as Z for UTC.") return parsed.timestamp() def parser() -> argparse.ArgumentParser: root = argparse.ArgumentParser(description="Record flights, then draw pen-plotter SVG maps.") sub = root.add_subparsers(dest="command", required=True) record = sub.add_parser("collect", help="Record OpenSky positions to SQLite; no map downloads") record.add_argument("--db", type=Path, required=True) record.add_argument( "--bounds", type=float, nargs=4, metavar=("LON1", "LAT1", "LON2", "LAT2"), help="Opposite corners, longitude first; default: Uithoorn", ) record.add_argument( "--interval", type=positive, help="Seconds between polls (default: 30, min: 10)" ) record.add_argument( "--collection-margin", type=positive, help="Metres outside map (default: 2000)" ) record.add_argument( "--duration", type=positive, help="Seconds to record; omit to run until Ctrl-C" ) record.add_argument( "--resume", metavar="SESSION", help="Resume a stopped/interrupted recording" ) listing = sub.add_parser("sessions", help="List saved recording sessions") listing.add_argument("--db", type=Path, required=True) purge = sub.add_parser("purge", help="Permanently delete one session and its observations") purge.add_argument("--db", type=Path, required=True) purge.add_argument("--session", required=True, help="Exact recording session ID to delete") purge.add_argument( "--yes", action="store_true", required=True, help="Confirm permanent deletion; stop collection for this session first", ) plot = sub.add_parser("plot", help="Draw from a saved session; never contacts OpenSky") plot.add_argument("--db", type=Path, required=True) plot.add_argument("--session", required=True) plot.add_argument("--output", type=Path, required=True) plot.add_argument("--from", dest="start", type=timestamp) plot.add_argument("--to", dest="end", type=timestamp) plot.add_argument("--width", type=positive, default=297, help="Page width in mm") plot.add_argument("--height", type=positive, default=210, help="Page height in mm") plot.add_argument("--margin", type=positive, default=12, help="Page margin in mm") plot.add_argument("--extension", type=positive, default=4, help="Track extension in mm") plot.add_argument("--pen-width", type=positive, default=0.3, help="Pen width in mm") plot.add_argument("--font-height", type=positive, default=1.8, help="Letter height in mm") plot.add_argument( "--max-gap", type=positive, default=120, help="Split tracks at gaps above seconds" ) plot.add_argument("--cache", type=Path, default=Path(".flight-map-cache")) plot.add_argument( "--refresh-map", action="store_true", help="Download fresh vector map data and atomically replace its cache entry", ) plot.add_argument( "--map-data", type=Path, help="Local Overpass JSON (out geom), for offline plotting" ) plot.add_argument( "--terrain", type=Path, help="Local terrain GeoTIFF; elevations must be in metres" ) plot.add_argument( "--terrain-reference", choices=("unknown", "wgs84-ellipsoid", "orthometric"), default="unknown", ) plot.add_argument( "--geoid", type=Path, help="Metre-valued h-H raster matching the terrain's orthometric datum", ) plot.add_argument( "--confirm-geometric-ellipsoid", action="store_true", help="Confirm OpenSky geometric heights can be treated as WGS84 ellipsoidal", ) plot.add_argument("--force", action="store_true", help="Replace an existing SVG") return root def main(argv: list[str] | None = None) -> int: args = parser().parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") try: if args.command == "collect": from flight_map.record import OpenSky, collect if args.resume and (args.bounds or args.interval or args.collection_margin): raise ValueError("Resuming uses stored bounds and interval; do not override them.") client = OpenSky() if args.resume and not args.db.exists(): raise ValueError("Cannot resume: recording database does not exist.") with database(args.db) as connection: initialize(connection) if args.resume: session = get_session(connection, args.resume) else: bounds = Bounds.corners(*args.bounds) if args.bounds else DEFAULT_BOUNDS session = create_session( connection, bounds, args.collection_margin or 2000, args.interval or 30, time.time(), ) print(f"Session: {session.id}", flush=True) print(f"Map bounds (W,S,E,N): {session.bounds.values}", flush=True) collect(connection, session, client, args.duration) elif args.command == "sessions": with database(args.db, readonly=True) as connection: for row in connection.execute("SELECT id FROM sessions ORDER BY started"): session = get_session(connection, row["id"]) count = connection.execute( "SELECT COUNT(*) FROM observations WHERE session_id = ?", (session.id,) ).fetchone()[0] started = datetime.fromtimestamp(session.started, UTC).isoformat() ended = datetime.fromtimestamp(session.ended, UTC).isoformat() print( f"{session.id} {session.status} {started} to {ended} " f"{count} observations bounds={session.bounds.values}" ) elif args.command == "purge": with database(args.db, must_exist=True) as connection: count = purge_session(connection, args.session) print(f"Purged session {args.session} and {count} observation(s).") else: from flight_map.plot import PlotOptions, render options = PlotOptions( args.width, args.height, args.margin, args.extension, args.pen_width, args.font_height, args.max_gap, ) count = render( args.db, args.session, args.output, start=args.start, end=args.end, options=options, cache=args.cache, local_map=args.map_data, terrain=args.terrain, terrain_reference=args.terrain_reference, geoid=args.geoid, confirm_geometric_ellipsoid=args.confirm_geometric_ellipsoid, force=args.force, refresh_map=args.refresh_map, ) print(f"Wrote {args.output} with {count} flight track(s).") except (ValueError, RuntimeError, OSError, sqlite3.Error) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 return 0