feat: AR-GPS initial commit — Python + JavaScript PyQt5 (standalone desktop app) + FastAPI (charts REST router) + OpenLayers (frontend map)
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"""Python ↔ JavaScript bridge for GPS Navigator (PyQt5 standalone).
|
||||
Exposed to JS as window.py via QWebChannel.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSlot, pyqtSignal
|
||||
|
||||
|
||||
class GPSBridge(QObject):
|
||||
# Signal: emitted from NMEA reader thread → received in JS via .connect()
|
||||
# Qt automatically queues cross-thread signal delivery — no asyncio needed.
|
||||
gpsMessage = pyqtSignal(str)
|
||||
|
||||
def __init__(self, db_path: Path, parent=None):
|
||||
super().__init__(parent)
|
||||
self._db_path = db_path
|
||||
self._reader = None
|
||||
self._last_fix: dict = {}
|
||||
self._track_interval = int(os.getenv("TRACK_INTERVAL_SEC", 5))
|
||||
self._track_counter = 0
|
||||
|
||||
# ── Called by NMEAReader background thread ────────────────────────────────
|
||||
def _on_nmea(self, msg: dict):
|
||||
if msg.get("type") == "position" and msg.get("fix_quality", 0) > 0:
|
||||
self._last_fix.update(msg)
|
||||
self._track_counter += 1
|
||||
if self._track_counter >= self._track_interval:
|
||||
self._track_counter = 0
|
||||
from backend.database import log_position
|
||||
log_position(self._db_path, self._last_fix)
|
||||
# Qt queues the signal delivery to the main thread — thread-safe.
|
||||
self.gpsMessage.emit(json.dumps(msg))
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
@pyqtSlot()
|
||||
def autodetect_and_start(self):
|
||||
"""Called by JS once QWebChannel is ready — ensures signal handler
|
||||
is connected before any GPS messages are emitted."""
|
||||
from backend.nmea_reader import NMEAReader
|
||||
if self._reader and self._reader.is_alive():
|
||||
return # already running
|
||||
port = os.getenv("GPS_PORT", "") or NMEAReader.autodetect()
|
||||
baud = int(os.getenv("GPS_BAUD", 9600))
|
||||
if port:
|
||||
self._reader = NMEAReader(port, baud, self._on_nmea)
|
||||
self._reader.start()
|
||||
else:
|
||||
# Emit a status so JS knows autodetect found nothing
|
||||
self.gpsMessage.emit(
|
||||
json.dumps({"type": "no_port", "msg": "No GPS port detected"})
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
if self._reader:
|
||||
self._reader.stop()
|
||||
self._reader = None
|
||||
|
||||
# ── GPS port management ───────────────────────────────────────────────────
|
||||
@pyqtSlot(result=str)
|
||||
def list_ports(self):
|
||||
from backend.nmea_reader import NMEAReader
|
||||
return json.dumps(NMEAReader.list_ports())
|
||||
|
||||
@pyqtSlot(str, int)
|
||||
def connect_gps(self, port: str, baud: int):
|
||||
from backend.nmea_reader import NMEAReader
|
||||
if self._reader and self._reader.is_alive():
|
||||
old = self._reader
|
||||
old.stop()
|
||||
# Join in background thread so we don't block the event loop
|
||||
def _restart():
|
||||
old.join(timeout=1.5)
|
||||
r = NMEAReader(port, baud, self._on_nmea)
|
||||
self._reader = r
|
||||
r.start()
|
||||
threading.Thread(target=_restart, daemon=True).start()
|
||||
else:
|
||||
self._reader = NMEAReader(port, baud, self._on_nmea)
|
||||
self._reader.start()
|
||||
|
||||
@pyqtSlot()
|
||||
def disconnect_gps(self):
|
||||
if self._reader:
|
||||
self._reader.stop()
|
||||
self._reader = None
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def get_status(self):
|
||||
return json.dumps({
|
||||
"connected": self._reader is not None and self._reader.is_alive(),
|
||||
"port": self._reader.port if self._reader else None,
|
||||
"fix": self._last_fix,
|
||||
})
|
||||
|
||||
# ── Waypoints ─────────────────────────────────────────────────────────────
|
||||
@pyqtSlot(result=str)
|
||||
def get_waypoints(self):
|
||||
from backend.database import get_waypoints
|
||||
return json.dumps(get_waypoints(self._db_path))
|
||||
|
||||
@pyqtSlot(str, result=str)
|
||||
def save_waypoint(self, data_json: str):
|
||||
from backend.database import save_waypoint
|
||||
return json.dumps(save_waypoint(self._db_path, json.loads(data_json)))
|
||||
|
||||
@pyqtSlot(str)
|
||||
def delete_waypoint(self, wid: str):
|
||||
from backend.database import delete_waypoint
|
||||
delete_waypoint(self._db_path, wid)
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────
|
||||
@pyqtSlot(result=str)
|
||||
def get_routes(self):
|
||||
from backend.database import get_routes
|
||||
return json.dumps(get_routes(self._db_path))
|
||||
|
||||
@pyqtSlot(str, result=str)
|
||||
def save_route(self, data_json: str):
|
||||
from backend.database import save_route
|
||||
return json.dumps(save_route(self._db_path, json.loads(data_json)))
|
||||
|
||||
@pyqtSlot(str)
|
||||
def delete_route(self, rid: str):
|
||||
from backend.database import delete_route
|
||||
delete_route(self._db_path, rid)
|
||||
|
||||
# ── Track log ─────────────────────────────────────────────────────────────
|
||||
@pyqtSlot(int, result=str)
|
||||
def get_track(self, limit: int):
|
||||
from backend.database import get_track
|
||||
return json.dumps(get_track(self._db_path, limit))
|
||||
|
||||
@pyqtSlot()
|
||||
def clear_track(self):
|
||||
from backend.database import clear_track
|
||||
clear_track(self._db_path)
|
||||
|
||||
# ── Charts ────────────────────────────────────────────────────────────────
|
||||
@pyqtSlot(result=str)
|
||||
def get_chart_cells(self):
|
||||
from backend.chart_manager import list_cells
|
||||
return json.dumps(list_cells())
|
||||
|
||||
# Allowed data types for get_cell_data — prevents path traversal via data_type
|
||||
_ALLOWED_DATA_TYPES = frozenset(
|
||||
{"features", "land", "depths", "hazards", "zones"}
|
||||
)
|
||||
|
||||
@pyqtSlot(str, str, result=str)
|
||||
def get_cell_data(self, cell_id: str, data_type: str):
|
||||
"""Lee el GeoJSON de una celda concreta (features/land/depths/hazards/zones).
|
||||
- Para depths: filtra SOUNDG (puntos individuales, no útiles en zoom general).
|
||||
- Si el resultado supera ~600KB: aplica decimación stride=3 en coordenadas
|
||||
para mantener cada mensaje QWebChannel dentro de límites seguros.
|
||||
Igual que ECDIS: un slot por celda/tipo — nunca agrega todas las celdas.
|
||||
IMPORTANTE: siempre retorna JSON válido aunque falle — si el slot lanza una
|
||||
excepción, PyQt5 puede no llamar al callback JS y la Promise queda colgada."""
|
||||
import logging as _log
|
||||
try:
|
||||
from backend.chart_manager import CHARTS_DIR
|
||||
|
||||
# Guard against path traversal: cell_id and data_type must not
|
||||
# contain path separators or parent-directory references.
|
||||
if (not cell_id
|
||||
or any(c in cell_id for c in ("/", "\\", "..", ":"))
|
||||
or data_type not in self._ALLOWED_DATA_TYPES):
|
||||
return json.dumps({'type': 'FeatureCollection', 'features': []})
|
||||
|
||||
path = CHARTS_DIR / cell_id / f'{data_type}.geojson'
|
||||
|
||||
# Ensure the resolved path stays inside CHARTS_DIR (defense in depth)
|
||||
try:
|
||||
path.resolve().relative_to(CHARTS_DIR.resolve())
|
||||
except ValueError:
|
||||
return json.dumps({'type': 'FeatureCollection', 'features': []})
|
||||
|
||||
if not path.exists():
|
||||
return json.dumps({'type': 'FeatureCollection', 'features': []})
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
feats = data.get('features', [])
|
||||
|
||||
# Filtrar SOUNDG (puntos de sondeo — demasiados para zoom general)
|
||||
if data_type == 'depths':
|
||||
feats = [ft for ft in feats
|
||||
if ft.get('properties', {}).get('layer') != 'SOUNDG']
|
||||
|
||||
# Decimación progresiva para mantener cada mensaje QWebChannel < ~700 KB.
|
||||
raw_json = json.dumps({'type': 'FeatureCollection', 'features': feats})
|
||||
if len(raw_json) > 600_000:
|
||||
feats = [self._decimate_feature(ft, stride=3) for ft in feats]
|
||||
raw_json = json.dumps({'type': 'FeatureCollection', 'features': feats})
|
||||
if len(raw_json) > 700_000:
|
||||
feats = [self._decimate_feature(ft, stride=5) for ft in feats]
|
||||
raw_json = json.dumps({'type': 'FeatureCollection', 'features': feats})
|
||||
|
||||
return raw_json
|
||||
except Exception as e:
|
||||
_log.getLogger(__name__).error(
|
||||
"get_cell_data %s/%s: %s", cell_id, data_type, e)
|
||||
return json.dumps({'type': 'FeatureCollection', 'features': []})
|
||||
|
||||
@staticmethod
|
||||
def _decimate_feature(feat: dict, stride: int) -> dict:
|
||||
"""Reduce densidad de vértices en polígonos/líneas para achicar el JSON."""
|
||||
import copy
|
||||
geom = copy.deepcopy(feat.get('geometry', {}))
|
||||
gtype = geom.get('type', '')
|
||||
|
||||
def dec_ring(ring):
|
||||
if len(ring) <= 6:
|
||||
return ring
|
||||
pts = ring[::stride]
|
||||
if pts[-1] != ring[-1]:
|
||||
pts.append(ring[-1])
|
||||
return pts
|
||||
|
||||
if gtype == 'Polygon':
|
||||
geom['coordinates'] = [dec_ring(r) for r in geom.get('coordinates', [])]
|
||||
elif gtype == 'MultiPolygon':
|
||||
geom['coordinates'] = [[dec_ring(r) for r in poly]
|
||||
for poly in geom.get('coordinates', [])]
|
||||
elif gtype == 'LineString':
|
||||
cs = geom.get('coordinates', [])
|
||||
geom['coordinates'] = cs[::stride] if len(cs) > 6 else cs
|
||||
|
||||
return {'type': feat.get('type', 'Feature'),
|
||||
'geometry': geom,
|
||||
'properties': feat.get('properties', {})}
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def get_chart_zones(self):
|
||||
from backend.chart_manager import get_all_zones
|
||||
return json.dumps(get_all_zones())
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def get_chart_land(self):
|
||||
from backend.chart_manager import get_all_land
|
||||
return json.dumps(get_all_land())
|
||||
|
||||
@pyqtSlot(str)
|
||||
def delete_chart(self, cell_id: str):
|
||||
from backend.chart_manager import delete_cell
|
||||
delete_cell(cell_id)
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def set_chart_region(self, cell_id: str, region: str):
|
||||
from backend.chart_manager import set_meta
|
||||
set_meta(cell_id, region=region.upper())
|
||||
|
||||
@pyqtSlot(str, result=str)
|
||||
def scan_charts_path(self, path: str):
|
||||
from backend.chart_manager import scan_and_install
|
||||
try:
|
||||
result = scan_and_install(path)
|
||||
except (FileNotFoundError, NotADirectoryError) as e:
|
||||
result = {"installed": [], "skipped": [],
|
||||
"errors": [{"file": path, "error": str(e)}]}
|
||||
except Exception as e:
|
||||
result = {"installed": [], "skipped": [],
|
||||
"errors": [{"file": path, "error": str(e)}]}
|
||||
return json.dumps(result)
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def open_chart_file_dialog(self):
|
||||
"""Open Qt native file dialog — install selected .000/.zip chart files."""
|
||||
import zipfile as _zf
|
||||
from PyQt5.QtWidgets import QFileDialog
|
||||
from backend.chart_manager import (
|
||||
install_from_enc, install_from_zip, install_from_csv_zip
|
||||
)
|
||||
|
||||
files, _ = QFileDialog.getOpenFileNames(
|
||||
None, "Open ENC Chart", "",
|
||||
"ENC Charts (*.000 *.zip);;All Files (*)"
|
||||
)
|
||||
if not files:
|
||||
return json.dumps({"installed": [], "skipped": [], "errors": []})
|
||||
|
||||
installed, skipped, errors = [], [], []
|
||||
for fpath in files:
|
||||
fp = Path(fpath)
|
||||
try:
|
||||
if fp.suffix.lower() == ".zip":
|
||||
with _zf.ZipFile(fp) 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)
|
||||
ids = install_from_csv_zip(fp) if (has_csv and not has_enc) \
|
||||
else install_from_zip(fp)
|
||||
installed.extend(ids)
|
||||
elif fp.suffix.upper() == ".000":
|
||||
cid = install_from_enc(fp, fp.stem.upper())
|
||||
installed.append(cid)
|
||||
except Exception as exc:
|
||||
errors.append({"file": fp.name, "error": str(exc)})
|
||||
|
||||
return json.dumps({"installed": installed, "skipped": skipped, "errors": errors})
|
||||
Reference in New Issue
Block a user