feat: AR-GPS initial commit — Python + JavaScript PyQt5 (standalone desktop app) + FastAPI (charts REST router) + OpenLayers (frontend map)

This commit is contained in:
2026-07-03 12:15:59 -04:00
commit 346bc1ffcb
19 changed files with 7149 additions and 0 deletions
View File
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
"""
REST API for S-57 ENC chart management GPS Navigator.
"""
import asyncio
import logging
import tempfile
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse
from pydantic import BaseModel
class ScanPathRequest(BaseModel):
path: str
from backend.chart_manager import (
install_from_zip, install_from_enc, list_cells,
delete_cell, get_all_features, get_all_depths,
get_all_land, get_all_hazards, get_all_zones,
CHARTS_DIR, set_meta,
install_from_csv_zip, scan_and_install,
)
router = APIRouter(prefix="/charts", tags=["charts"])
log = logging.getLogger(__name__)
@router.get("/cells")
def get_cells():
return list_cells()
@router.post("/upload")
async def upload_chart(file: UploadFile = File(...)):
"""
Upload a chart file.
Accepts:
.000 single S-57 ENC cell
.zip NOAA ENC zip (contains .000) OR CSV-based custom zip
"""
suffix = Path(file.filename or "").suffix.lower()
if suffix not in (".zip", ".000"):
raise HTTPException(400, "Only .zip or .000 files accepted")
data = await file.read()
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
if suffix == ".zip":
# Auto-detect CSV vs ENC zip
import zipfile as _zf
with _zf.ZipFile(tmp_path) as z:
names = z.namelist()
has_csv = any(n.lower().endswith(".csv") for n in names)
has_enc = any(n.upper().endswith(".000") for n in names)
if has_csv and not has_enc:
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_zip, tmp_path)
else:
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_zip, tmp_path)
else:
orig_name = Path(file.filename).stem.upper() if file.filename else None
cell_id = await asyncio.get_event_loop().run_in_executor(
None, install_from_enc, tmp_path, orig_name)
installed = [cell_id]
except Exception as e:
log.exception("Chart upload failed: %s", e)
raise HTTPException(500, "Chart processing failed — check server logs for details")
finally:
tmp_path.unlink(missing_ok=True)
return {"installed": installed}
@router.delete("/cells/{cell_id}")
def remove_cell(cell_id: str):
delete_cell(cell_id)
return {"deleted": cell_id.upper()}
@router.patch("/cells/{cell_id}/region")
def set_cell_region(cell_id: str, region: str):
region = (region or "").upper().strip()
if region not in ("A", "B"):
raise HTTPException(400, "region must be 'A' or 'B'")
try:
set_meta(cell_id, region=region)
except FileNotFoundError:
raise HTTPException(404, f"Cell {cell_id} not installed")
return {"id": cell_id.upper(), "region": region}
@router.get("/features")
def chart_features():
return JSONResponse(get_all_features())
@router.get("/depths")
def chart_depths(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
return JSONResponse(get_all_depths(bbox))
@router.get("/land")
def chart_land():
return JSONResponse(get_all_land())
@router.get("/hazards")
def chart_hazards():
return JSONResponse(get_all_hazards())
@router.get("/zones")
def chart_zones():
return JSONResponse(get_all_zones())
@router.post("/scan-path")
async def scan_path(body: ScanPathRequest):
"""
Scan a local directory (e.g. SD card drive letter) for .000 / .zip chart
files and install them.
Body: { "path": "E:\\ENC_Charts" }
"""
directory = (body.path or "").strip()
if not directory:
raise HTTPException(400, "path is required")
try:
result = await asyncio.get_event_loop().run_in_executor(
None, scan_and_install, directory)
except (FileNotFoundError, NotADirectoryError) as exc:
raise HTTPException(404, str(exc))
except Exception as exc:
log.exception("scan-path failed: %s", exc)
raise HTTPException(500, "Scan failed — check server logs for details")
return result
+160
View File
@@ -0,0 +1,160 @@
"""SQLite persistence for waypoints and routes."""
import sqlite3, json, uuid
from pathlib import Path
from datetime import datetime, timezone
def _conn(db_path: Path):
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
return con
def init_db(db_path: Path):
db_path.parent.mkdir(parents=True, exist_ok=True)
con = _conn(db_path)
con.executescript("""
CREATE TABLE IF NOT EXISTS waypoints (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
notes TEXT,
mark_type TEXT DEFAULT '',
locked INTEGER DEFAULT 0,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS routes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
wpt_ids TEXT NOT NULL, -- JSON array of waypoint ids in order
created_at TEXT
);
CREATE TABLE IF NOT EXISTS track_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL,
lon REAL,
sog REAL,
cog REAL,
alt REAL,
hdop REAL,
ts TEXT
);
""")
con.commit()
# Migration: add mark_type column to existing DBs that don't have it
try:
con.execute("ALTER TABLE waypoints ADD COLUMN mark_type TEXT DEFAULT ''")
con.commit()
except Exception:
pass # column already exists
try:
con.execute("ALTER TABLE waypoints ADD COLUMN locked INTEGER DEFAULT 0")
con.commit()
except Exception:
pass
con.close()
# ── Waypoints ─────────────────────────────────────────────────────────────────
def get_waypoints(db_path: Path) -> list:
con = _conn(db_path)
rows = con.execute("SELECT * FROM waypoints ORDER BY created_at").fetchall()
con.close()
return [dict(r) for r in rows]
def save_waypoint(db_path: Path, data: dict) -> dict:
wid = data.get("id") or str(uuid.uuid4())[:8].upper()
now = datetime.now(timezone.utc).isoformat()
con = _conn(db_path)
con.execute("""
INSERT INTO waypoints (id, name, lat, lon, notes, mark_type, locked, created_at)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, lat=excluded.lat, lon=excluded.lon,
notes=excluded.notes, mark_type=excluded.mark_type,
locked=excluded.locked
""", (wid, data["name"], data["lat"], data["lon"],
data.get("notes", ""), data.get("mark_type", ""),
int(data.get("locked", 0)),
data.get("created_at", now)))
con.commit()
con.close()
return {**data, "id": wid, "created_at": now}
def delete_waypoint(db_path: Path, wid: str):
con = _conn(db_path)
con.execute("DELETE FROM waypoints WHERE id=?", (wid,))
con.commit()
con.close()
# ── Routes ────────────────────────────────────────────────────────────────────
def get_routes(db_path: Path) -> list:
con = _conn(db_path)
rows = con.execute("SELECT * FROM routes ORDER BY created_at").fetchall()
con.close()
result = []
for r in rows:
d = dict(r)
d["wpt_ids"] = json.loads(d["wpt_ids"])
result.append(d)
return result
def save_route(db_path: Path, data: dict) -> dict:
rid = data.get("id") or str(uuid.uuid4())[:8].upper()
now = datetime.now(timezone.utc).isoformat()
con = _conn(db_path)
con.execute("""
INSERT INTO routes (id, name, wpt_ids, created_at)
VALUES (?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, wpt_ids=excluded.wpt_ids
""", (rid, data["name"], json.dumps(data.get("wpt_ids", [])),
data.get("created_at", now)))
con.commit()
con.close()
return {**data, "id": rid, "wpt_ids": data.get("wpt_ids", []), "created_at": now}
def delete_route(db_path: Path, rid: str):
con = _conn(db_path)
con.execute("DELETE FROM routes WHERE id=?", (rid,))
con.commit()
con.close()
# ── Track log ─────────────────────────────────────────────────────────────────
def log_position(db_path: Path, fix: dict):
con = _conn(db_path)
con.execute(
"INSERT INTO track_log (lat,lon,sog,cog,alt,hdop,ts) VALUES (?,?,?,?,?,?,?)",
(fix.get("lat"), fix.get("lon"), fix.get("sog"), fix.get("cog"),
fix.get("altitude"), fix.get("hdop"),
datetime.now(timezone.utc).isoformat())
)
con.commit()
con.close()
def get_track(db_path: Path, limit: int = 2000) -> list:
con = _conn(db_path)
rows = con.execute(
"SELECT lat,lon,sog,cog,alt,hdop,ts FROM track_log ORDER BY id DESC LIMIT ?",
(limit,)
).fetchall()
con.close()
return [dict(r) for r in reversed(rows)]
def clear_track(db_path: Path):
con = _conn(db_path)
con.execute("DELETE FROM track_log")
con.commit()
con.close()
+210
View File
@@ -0,0 +1,210 @@
"""NMEA 0183 serial reader — parses GGA, RMC, VTG, GSV, GSA, GLL.
Runs in a background thread; calls broadcast_fn(msg) directly.
Thread safety is handled by the caller (Qt signal emit or similar)."""
import threading, serial, serial.tools.list_ports
# Known USB-serial VIDs: u-blox, CH340, FTDI, Prolific
KNOWN_VIDS = {0x1546, 0x1A86, 0x0403, 0x067B}
SYSTEM_MAP = {"GP": "GPS", "GL": "GLONASS", "GA": "Galileo",
"GB": "BeiDou", "GN": "GNSS", "GQ": "QZSS"}
class NMEAReader(threading.Thread):
def __init__(self, port: str, baud: int, broadcast_fn):
super().__init__(daemon=True, name="nmea-reader")
self.port = port
self.baud = baud
self._bcast = broadcast_fn
self._stop = threading.Event()
self._fix = {}
self._sats = {} # key="{sys}_{prn}" → dict
self._active = set() # PRN strings from GSA
# ── Auto-detect ───────────────────────────────────────────────────────────
@staticmethod
def autodetect() -> str | None:
"""Try to find a GPS serial port.
1. Match by USB vendor ID (u-blox, CH340, FTDI, Prolific) fast and reliable.
2. If no VID match, return the first available COM port from the OS list
(works for USB-CDC devices that don't expose a VID, e.g. some clone adapters).
"""
ports = serial.tools.list_ports.comports()
# Priority: well-known GPS USB chip vendor IDs
for p in ports:
if (p.vid or 0) in KNOWN_VIDS:
return p.device
# Fallback: first port in the system list (avoid brute-force which can hang on BT ports)
if ports:
return ports[0].device
return None
@staticmethod
def list_ports():
return [{"port": p.device, "desc": p.description,
"vid": p.vid, "pid": p.pid}
for p in serial.tools.list_ports.comports()]
# ── Thread control ────────────────────────────────────────────────────────
def stop(self):
self._stop.set()
def _emit(self, msg: dict):
self._bcast(msg)
# ── Main loop ─────────────────────────────────────────────────────────────
def run(self):
try:
ser = serial.Serial(self.port, self.baud, timeout=0.3)
except Exception as e:
self._emit({"type": "error", "msg": str(e)})
return
self._emit({"type": "connected", "port": self.port, "baud": self.baud})
while not self._stop.is_set():
try:
raw = ser.readline()
if not raw:
continue
line = raw.decode("ascii", errors="replace").strip()
if not line.startswith("$"):
continue
self._emit({"type": "raw", "sentence": line})
self._parse(line)
except serial.SerialException as e:
self._emit({"type": "error", "msg": str(e)})
break
except Exception:
pass
ser.close()
self._emit({"type": "disconnected"})
# ── NMEA dispatch ─────────────────────────────────────────────────────────
def _parse(self, line: str):
body = line[1:line.index("*")] if "*" in line else line[1:]
parts = body.split(",")
if not parts:
return
talker = parts[0][:2]
sentence = parts[0][2:]
dispatch = {
"GGA": self._gga, "RMC": self._rmc, "VTG": self._vtg,
"GSV": self._gsv, "GSA": self._gsa, "GLL": self._gll,
}
fn = dispatch.get(sentence)
if fn:
try:
fn(parts, talker)
except Exception:
pass
# ── Helpers ───────────────────────────────────────────────────────────────
@staticmethod
def _lat(v, h):
if not v: return None
d = int(v[:2]); m = float(v[2:])
return -(d + m/60) if h == "S" else (d + m/60)
@staticmethod
def _lon(v, h):
if not v: return None
d = int(v[:3]); m = float(v[3:])
return -(d + m/60) if h == "W" else (d + m/60)
@staticmethod
def _f(v):
s = v.split("*")[0].strip() if v else ""
return float(s) if s else None
@staticmethod
def _i(v):
s = v.split("*")[0].strip() if v else ""
return int(s) if s else None
def _sat_list(self):
return list(self._sats.values())
# ── Sentence handlers ─────────────────────────────────────────────────────
def _gga(self, p, talker):
if len(p) < 10: return
lat = self._lat(p[2], p[3])
lon = self._lon(p[4], p[5])
fq = self._i(p[6]) or 0
sats = self._i(p[7]) or 0
hdop = self._f(p[8])
alt = self._f(p[9])
self._fix.update({"lat": lat, "lon": lon, "fix_quality": fq,
"satellites": sats, "hdop": hdop, "altitude": alt,
"utc": p[1]})
self._emit({"type": "position", **self._fix, "sats": self._sat_list()})
def _rmc(self, p, talker):
if len(p) < 9: return
if p[2] != "A": return # void
lat = self._lat(p[3], p[4])
lon = self._lon(p[5], p[6])
sog = self._f(p[7])
cog = self._f(p[8])
magvar = None
if len(p) > 11 and p[10]:
mv = self._f(p[10])
if mv is not None:
magvar = -mv if (len(p) > 11 and p[11].startswith("W")) else mv
self._fix.update({"sog": sog, "cog": cog, "magvar": magvar,
"date": p[9]})
self._emit({"type": "rmc", "lat": lat, "lon": lon,
"sog": sog, "cog": cog, "magvar": magvar, "date": p[9]})
def _vtg(self, p, talker):
if len(p) < 8: return
mode = p[9].split("*")[0] if len(p) > 9 else ""
if mode == "N": return
cog_t = self._f(p[1])
cog_m = self._f(p[3])
sog = self._f(p[5])
self._fix.update({"cog": cog_t, "cog_m": cog_m, "sog": sog})
def _gsv(self, p, talker):
sys = SYSTEM_MAP.get(talker, talker)
i = 4
while i + 2 < len(p):
prn = p[i].strip()
el = self._i(p[i+1]) if i+1 < len(p) else None
az = self._i(p[i+2]) if i+2 < len(p) else None
snr_raw = p[i+3] if i+3 < len(p) else ""
snr = self._i(snr_raw)
if prn:
key = f"{sys}_{prn}"
self._sats[key] = {
"key": key, "prn": prn, "system": sys,
"el": el, "az": az, "snr": snr,
"used": prn in self._active,
}
i += 4
self._emit({"type": "satellites", "sats": self._sat_list()})
def _gsa(self, p, talker):
if len(p) < 15: return
self._active = {p[i].strip() for i in range(3, 15) if p[i].strip()}
fix_mode = self._i(p[2]) or 1
pdop = self._f(p[15]) if len(p) > 15 else None
hdop = self._f(p[16]) if len(p) > 16 else None
vdop = self._f(p[17]) if len(p) > 17 else None
self._fix.update({"fix_mode": fix_mode, "pdop": pdop,
"hdop": hdop, "vdop": vdop})
self._emit({"type": "dop", "fix_mode": fix_mode,
"pdop": pdop, "hdop": hdop, "vdop": vdop})
# refresh used flag
for k, s in self._sats.items():
s["used"] = s["prn"] in self._active
def _gll(self, p, talker):
if len(p) < 6: return
status = p[6] if len(p) > 6 else p[5]
if "A" not in status: return
lat = self._lat(p[1], p[2])
lon = self._lon(p[3], p[4])
if lat and lon:
self._fix.update({"lat": lat, "lon": lon})