feat: AR-GPS initial commit — Python + JavaScript PyQt5 (standalone desktop app) + FastAPI (charts REST router) + OpenLayers (frontend map)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user