import asyncio import base64 import json import math import re import socket import threading import time import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any from bleak import BleakClient, BleakScanner from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from rtcm_parser import RTCMParser APP_DIR = Path(__file__).parent LOG_DIR = APP_DIR / "logs" EARTH_RADIUS_M = 6371008.8 NWS_TIMEOUT_S = 8 NWS_USER_AGENT = "maglink-gnss-logger/1.0" NUS_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e" NUS_RX_WRITE = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" NUS_TX_NOTIFY = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" # NTRIP configuration (hardcoded for initial implementation) NTRIP_CASTER_HOST = "truertk.pointonenav.com" NTRIP_CASTER_PORT = 2101 NTRIP_MOUNTPOINT = "AUTO" NTRIP_USERNAME = "9t7fwfbm57" NTRIP_PASSWORD = "96m7bec9g8" NTRIP_LAT = 36.1140884 NTRIP_LON = -97.0880663 NTRIP_ALT = 390.0 RTK_FIXED_STREAK_LENGTH = 5 COMMANDS: list[dict[str, Any]] = [ { "name": "AT+APN", "title": "APN Configuration", "description": "Configure cellular APN settings.", "set_params": [ {"name": "flag", "label": "Custom APN", "type": "select", "options": [["0", "Default"], ["1", "Custom"]]}, {"name": "apn", "label": "APN", "type": "text", "optional": True}, {"name": "username", "label": "Username", "type": "text", "optional": True}, {"name": "password", "label": "Password", "type": "password", "optional": True}, ], "examples": ["AT+APN=SET,0", "AT+APN=SET,1,internet.v6.telekom,telekom,tm"], }, { "name": "AT+OLEDROTATE", "title": "OLED Rotation", "description": "Set display orientation.", "set_params": [ {"name": "angle", "label": "Rotation", "type": "select", "options": [["0", "Normal"], ["1", "180 degrees"]]}, ], "examples": ["AT+OLEDROTATE=SET,0", "AT+OLEDROTATE=SET,1"], }, { "name": "AT+BT_OUT", "title": "Bluetooth Output", "description": "Configure Bluetooth output mode and sentence selection.", "set_params": [ {"name": "type", "label": "Mode", "type": "select", "options": [["0", "Standard"], ["1", "Custom"]]}, {"name": "json", "label": "JSON", "type": "bool", "optional": True}, {"name": "gnpos", "label": "GNPOS", "type": "bool", "optional": True}, {"name": "gndev", "label": "GNDEV", "type": "bool", "optional": True}, {"name": "gga", "label": "GGA", "type": "bool", "optional": True}, {"name": "gst", "label": "GST", "type": "bool", "optional": True}, {"name": "rmc", "label": "RMC", "type": "bool", "optional": True}, {"name": "vtg", "label": "VTG", "type": "bool", "optional": True}, {"name": "gsv", "label": "GSV", "type": "bool", "optional": True}, {"name": "gsa", "label": "GSA", "type": "bool", "optional": True}, ], "examples": ["AT+BT_OUT=SET,0", "AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0"], }, { "name": "AT+UPLOADDATA_PARM", "title": "Upload Server", "description": "Set upload frequency, server address, and server port.", "set_params": [ { "name": "freq", "label": "Frequency", "type": "select", "options": [["0", "Off"], ["1", "1 sec"], ["2", "2 sec"], ["5", "5 sec"], ["10", "10 sec"], ["255", "Follow GGA"]], }, {"name": "server", "label": "Server", "type": "text", "optional": True}, {"name": "port", "label": "Port", "type": "number", "optional": True}, ], "examples": ["AT+UPLOADDATA_PARM=SET,0", "AT+UPLOADDATA_PARM=SET,1,mqtt.example.com,1883"], }, { "name": "AT+UPLOADDATA_TYPE", "title": "Upload Protocol", "description": "Set upload protocol and optional MQTT authentication/publish settings.", "set_params": [ {"name": "type", "label": "Protocol", "type": "select", "options": [["0", "TCP"], ["1", "HTTP"], ["2", "MQTT"], ["3", "JT808"]]}, {"name": "username", "label": "Username", "type": "text", "optional": True, "prefix": "USERNAME"}, {"name": "password", "label": "Password", "type": "password", "optional": True, "prefix": "PASSWORD"}, {"name": "clientid", "label": "Client ID", "type": "text", "optional": True, "prefix": "CLIENTID"}, {"name": "topic", "label": "Topic", "type": "text", "optional": True, "prefix": "TOPIC"}, ], "examples": [ "AT+UPLOADDATA_TYPE=SET,0", "AT+UPLOADDATA_TYPE=SET,2,USERNAME,myuser,PASSWORD,mypass,CLIENTID,device001,TOPIC,/gnss/data", ], }, { "name": "AT+ROVER_PARM", "title": "Rover NTRIP", "description": "Configure NTRIP client parameters for rover mode.", "set_params": [ {"name": "enable", "label": "Enable", "type": "select", "options": [["0", "Disabled"], ["1", "Enabled"]]}, {"name": "server", "label": "Server", "type": "text", "optional": True}, {"name": "port", "label": "Port", "type": "number", "optional": True}, {"name": "mountpoint", "label": "Mountpoint", "type": "text", "optional": True}, {"name": "username", "label": "Username", "type": "text", "optional": True}, {"name": "password", "label": "Password", "type": "password", "optional": True}, ], "examples": ["AT+ROVER_PARM=SET,0", "AT+ROVER_PARM=SET,1,rtk.server.com,2101,MOUNT01,user,pass"], }, { "name": "AT+BASE_PARM", "title": "Base Station", "description": "Configure base station correction output.", "set_params": [ {"name": "mode", "label": "Mode", "type": "select", "options": [["0", "Disabled"], ["1", "TCP Server"], ["2", "NTRIP Caster"]]}, {"name": "server", "label": "Server", "type": "text", "optional": True}, {"name": "port", "label": "Port", "type": "number", "optional": True}, {"name": "mountpoint", "label": "Mountpoint", "type": "text", "optional": True}, {"name": "username", "label": "Username", "type": "text", "optional": True}, {"name": "password", "label": "Password", "type": "password", "optional": True}, ], "examples": ["AT+BASE_PARM=SET,0", "AT+BASE_PARM=SET,2,caster.server.com,2101,BASE01,user,pass"], }, { "name": "AT+GNSS_MODE", "title": "GNSS Mode", "description": "Set rover, base, or static operating mode.", "set_params": [ {"name": "mode", "label": "Mode", "type": "select", "options": [["0", "Rover"], ["1", "Base"], ["2", "Static"]]}, ], "examples": ["AT+GNSS_MODE=SET,0", "AT+GNSS_MODE=SET,1", "AT+GNSS_MODE=SET,2"], }, { "name": "AT+DEV_INIT_STA", "title": "Device Initialization Status", "description": "Query 4G, SIM, NTRIP, upload queue, GNSS, and satellite SNR status.", "set_params": [], "examples": ["AT+DEV_INIT_STA=GET"], }, { "name": "AT+NEMATIME", "title": "NMEA Output Frequency", "description": "Set or query the GNSS/NMEA position output frequency.", "set_params": [ {"name": "frequency", "label": "Frequency", "type": "select", "options": [["1", "1 Hz"], ["2", "2 Hz"], ["5", "5 Hz"], ["10", "10 Hz"]]}, ], "examples": ["AT+NEMATIME=GET", "AT+NEMATIME=SET,1", "AT+NEMATIME=SET,5", "AT+NEMATIME=SET,10"], }, { "name": "AT+RTCMBASEPOS", "title": "RTCM Base Position", "description": "Query the RTCM reference station latitude, longitude, altitude, and distance.", "set_params": [], "examples": ["AT+RTCMBASEPOS=GET"], }, ] class ConnectRequest(BaseModel): address: str tx_char: str | None = None rx_char: str | None = None class CharacteristicRequest(BaseModel): tx_char: str | None = None rx_char: str | None = None class SendRequest(BaseModel): command: str = Field(min_length=1) append_crlf: bool = True response: bool = False class BuildCommandRequest(BaseModel): name: str action: str params: list[str | None] = [] class ScanRequest(BaseModel): timeout: float = Field(default=5.0, ge=1.0, le=30.0) class MeasurementStartRequest(BaseModel): notes: str = Field(default="", max_length=4000) class NTRIPConnectRequest(BaseModel): host: str | None = None port: int | None = None mountpoint: str | None = None username: str | None = None password: str | None = None latitude: float | None = None longitude: float | None = None altitude: float | None = None @dataclass class ParsedLine: kind: str data: dict[str, Any] | None = None checksum_ok: bool | None = None class Hub: def __init__(self) -> None: self.websockets: set[WebSocket] = set() self.history: list[dict[str, Any]] = [] self.max_history = 250 async def connect(self, websocket: WebSocket) -> None: await websocket.accept() self.websockets.add(websocket) for event in self.history[-75:]: await websocket.send_json(event) def disconnect(self, websocket: WebSocket) -> None: self.websockets.discard(websocket) async def broadcast(self, event: dict[str, Any]) -> None: event.setdefault("ts", datetime.now(timezone.utc).isoformat()) self.history.append(event) self.history = self.history[-self.max_history :] stale: list[WebSocket] = [] for websocket in list(self.websockets): try: await websocket.send_json(event) except Exception: stale.append(websocket) for websocket in stale: self.disconnect(websocket) hub = Hub() def utc_now() -> str: return datetime.now(timezone.utc).isoformat() def clean_uuid(uuid: str | None) -> str | None: return uuid.lower() if uuid else None def calculate_nmea_checksum(sentence: str) -> int: data = sentence.strip().lstrip("$").split("*", 1)[0] checksum = 0 for char in data: checksum ^= ord(char) return checksum def verify_nmea_checksum(sentence: str) -> bool: if "*" not in sentence: return False try: received = int(sentence.split("*", 1)[1][:2], 16) except ValueError: return False return calculate_nmea_checksum(sentence) == received def parse_float(value: str) -> float | None: return float(value) if value else None def parse_int(value: str) -> int | None: return int(value) if value else None def parse_nmea_coordinate(value: str, hemisphere: str, degree_digits: int) -> float | None: if not value or not hemisphere: return None try: degrees = float(value[:degree_digits]) minutes = float(value[degree_digits:]) except ValueError: return None coordinate = degrees + minutes / 60.0 if hemisphere.upper() in {"S", "W"}: coordinate *= -1 return coordinate def parse_gnpos(sentence: str) -> dict[str, Any] | None: data = sentence.strip().split("*", 1)[0].lstrip("$") fields = data.split(",") if len(fields) != 20 or fields[0] != "GNPOS": return None status = parse_int(fields[5]) status_names = {0: "No Fix", 1: "Single Point", 2: "DGPS", 4: "RTK Fixed", 5: "RTK Float"} return { "latitude": parse_float(fields[1]), "longitude": parse_float(fields[2]), "altitude_m": parse_float(fields[3]), "altitude_corrected_m": parse_float(fields[4]), "status": status, "status_text": status_names.get(status, "Unknown"), "hdop": parse_float(fields[6]), "hrms_m": parse_float(fields[7]), "vrms_m": parse_float(fields[8]), "satellites_used": parse_int(fields[9]), "satellites_visible": parse_int(fields[10]), "speed_kmh": parse_float(fields[11]), "heading_deg": parse_float(fields[12]), "battery_voltage": parse_float(fields[13]), "battery_percent": parse_int(fields[14]), "ntrip_connected": bool(parse_int(fields[15]) or 0), "rtcm_size_bytes": parse_int(fields[16]), "correction_age_s": parse_float(fields[17]), "timestamp": parse_int(fields[18]), "tilt_angle_deg": parse_float(fields[19]), } def parse_gga(sentence: str) -> dict[str, Any] | None: data = sentence.strip().split("*", 1)[0].lstrip("$") fields = data.split(",") if len(fields) < 15 or not fields[0].endswith("GGA"): return None quality = parse_int(fields[6]) quality_names = {0: "No Fix", 1: "GPS Fix", 2: "DGPS", 4: "RTK Fixed", 5: "RTK Float"} return { "latitude": parse_nmea_coordinate(fields[2], fields[3], 2), "longitude": parse_nmea_coordinate(fields[4], fields[5], 3), "altitude_m": parse_float(fields[9]), "status": quality, "status_text": quality_names.get(quality, "Unknown"), "hdop": parse_float(fields[8]), "satellites_used": parse_int(fields[7]), "timestamp": fields[1] or None, "sentence_type": fields[0], } def parse_rmc(sentence: str) -> dict[str, Any] | None: data = sentence.strip().split("*", 1)[0].lstrip("$") fields = data.split(",") if len(fields) < 12 or not fields[0].endswith("RMC"): return None valid = fields[2] == "A" speed_knots = parse_float(fields[7]) return { "latitude": parse_nmea_coordinate(fields[3], fields[4], 2) if valid else None, "longitude": parse_nmea_coordinate(fields[5], fields[6], 3) if valid else None, "status": 1 if valid else 0, "status_text": "Valid" if valid else "No Fix", "speed_kmh": speed_knots * 1.852 if speed_knots is not None else None, "heading_deg": parse_float(fields[8]), "timestamp": fields[1] or None, "date": fields[9] or None, "sentence_type": fields[0], } def parse_gndev(sentence: str) -> dict[str, Any] | None: data = sentence.strip().split("*", 1)[0].lstrip("$") fields = data.split(",") if len(fields) != 7 or fields[0] != "GNDEV": return None return { "serial_number": fields[1], "pcb_version": fields[2], "firmware_version": fields[3], "imei": fields[4], "imsi": fields[5], "iccid": fields[6], } def parse_line(line: str) -> ParsedLine: if line.startswith("$GNPOS,"): checksum_ok = verify_nmea_checksum(line) return ParsedLine("gnpos", parse_gnpos(line) if checksum_ok else None, checksum_ok) if line.startswith("$GNDEV,"): checksum_ok = verify_nmea_checksum(line) return ParsedLine("gndev", parse_gndev(line) if checksum_ok else None, checksum_ok) if line.startswith("$"): checksum_ok = verify_nmea_checksum(line) sentence_type = line.strip().split("*", 1)[0].lstrip("$").split(",", 1)[0] if sentence_type.endswith("GGA"): return ParsedLine("gga", parse_gga(line) if checksum_ok else None, checksum_ok) if sentence_type.endswith("RMC"): return ParsedLine("rmc", parse_rmc(line) if checksum_ok else None, checksum_ok) return ParsedLine("nmea", None, checksum_ok) if line == "OK": return ParsedLine("ok") if line == "ERROR": return ParsedLine("error") if line.startswith("AT+"): return ParsedLine("at_response") return ParsedLine("text") def format_command(name: str, action: str, params: list[str | None]) -> str: action = action.upper() if action not in {"GET", "SET"}: raise ValueError("action must be GET or SET") command = f"{name}={action}" if action == "SET": normalized = ["" if p is None else str(p) for p in params] while normalized and normalized[-1] == "": normalized.pop() if normalized: command += "," + ",".join(normalized) return command def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: lat1_rad = math.radians(lat1) lat2_rad = math.radians(lat2) delta_lat = math.radians(lat2 - lat1) delta_lon = math.radians(lon2 - lon1) a = math.sin(delta_lat / 2) ** 2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2 return 2 * EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a))) def point_offsets_m(point: dict[str, Any], origin_lat: float, origin_lon: float) -> tuple[float, float]: lat = float(point["latitude"]) lon = float(point["longitude"]) east = haversine_m(origin_lat, origin_lon, origin_lat, lon) north = haversine_m(origin_lat, origin_lon, lat, origin_lon) if lon < origin_lon: east *= -1 if lat < origin_lat: north *= -1 return east, north def percentile(values: list[float], pct: float) -> float | None: if not values: return None ordered = sorted(values) if len(ordered) == 1: return ordered[0] rank = (len(ordered) - 1) * pct lower = math.floor(rank) upper = math.ceil(rank) if lower == upper: return ordered[lower] return ordered[lower] + (ordered[upper] - ordered[lower]) * (rank - lower) def numeric_values(points: list[dict[str, Any]], key: str) -> list[float]: values = [] for point in points: value = point.get(key) if value is None: continue try: values.append(float(value)) except (TypeError, ValueError): continue return values def is_rtk_fixed_point(point: dict[str, Any]) -> bool: try: return int(point.get("status")) == 4 except (TypeError, ValueError): return False def rtk_fixed_streak_points(points: list[dict[str, Any]], streak_length: int = RTK_FIXED_STREAK_LENGTH) -> list[dict[str, Any]]: selected = [] streak = 0 for point in points: if is_rtk_fixed_point(point): streak += 1 if streak >= streak_length: selected.append(point) else: streak = 0 return selected def mean(values: list[float]) -> float | None: return sum(values) / len(values) if values else None def root_mean_square(values: list[float]) -> float | None: return math.sqrt(sum(value * value for value in values) / len(values)) if values else None def calculate_position_metrics(points: list[dict[str, Any]]) -> dict[str, Any]: valid_points = [ point for point in points if point.get("latitude") is not None and point.get("longitude") is not None ] count = len(valid_points) if count == 0: return {"count": 0} mean_lat = sum(float(point["latitude"]) for point in valid_points) / count mean_lon = sum(float(point["longitude"]) for point in valid_points) / count offsets = [point_offsets_m(point, mean_lat, mean_lon) for point in valid_points] radial_errors = [haversine_m(mean_lat, mean_lon, float(point["latitude"]), float(point["longitude"])) for point in valid_points] east_errors = [offset[0] for offset in offsets] north_errors = [offset[1] for offset in offsets] rms = math.sqrt(sum(error * error for error in radial_errors) / count) std_e = math.sqrt(sum(error * error for error in east_errors) / count) std_n = math.sqrt(sum(error * error for error in north_errors) / count) span_e = max(east_errors) - min(east_errors) span_n = max(north_errors) - min(north_errors) hrms_values = numeric_values(valid_points, "hrms_m") vrms_values = numeric_values(valid_points, "vrms_m") receiver_hrms_rms = root_mean_square(hrms_values) within_receiver_hrms = [ error <= float(point["hrms_m"]) for point, error in zip(valid_points, radial_errors) if point.get("hrms_m") is not None ] status_counts: dict[str, int] = {} for point in valid_points: status = point.get("status_text") or point.get("status") label = str(status) if status is not None else "Unknown" status_counts[label] = status_counts.get(label, 0) + 1 return { "count": count, "gnpos_count": sum(1 for point in valid_points if point.get("source") == "gnpos"), "mean_latitude": mean_lat, "mean_longitude": mean_lon, "rms_m": rms, "cep50_m": percentile(radial_errors, 0.50), "cep95_m": percentile(radial_errors, 0.95), "r95_m": percentile(radial_errors, 0.95), "drms_m": math.sqrt(std_e * std_e + std_n * std_n), "two_drms_m": 2 * math.sqrt(std_e * std_e + std_n * std_n), "std_east_m": std_e, "std_north_m": std_n, "mean_error_m": sum(radial_errors) / count, "max_error_m": max(radial_errors), "span_east_m": span_e, "span_north_m": span_n, "span_2d_m": math.sqrt(span_e * span_e + span_n * span_n), "receiver_estimate_count": len(hrms_values), "receiver_hrms_mean_m": mean(hrms_values), "receiver_hrms_rms_m": receiver_hrms_rms, "receiver_hrms_min_m": min(hrms_values) if hrms_values else None, "receiver_hrms_max_m": max(hrms_values) if hrms_values else None, "receiver_hrms_latest_m": hrms_values[-1] if hrms_values else None, "receiver_vrms_mean_m": mean(vrms_values), "receiver_vrms_rms_m": root_mean_square(vrms_values), "receiver_vrms_latest_m": vrms_values[-1] if vrms_values else None, "rms_minus_receiver_hrms_m": rms - receiver_hrms_rms if receiver_hrms_rms is not None else None, "rms_to_receiver_hrms_ratio": rms / receiver_hrms_rms if receiver_hrms_rms and receiver_hrms_rms > 0 else None, "within_receiver_hrms_percent": (sum(within_receiver_hrms) / len(within_receiver_hrms) * 100) if within_receiver_hrms else None, "status_counts": status_counts, } def make_log_point(line: str, kind: str, data: dict[str, Any]) -> dict[str, Any] | None: lat = data.get("latitude") lon = data.get("longitude") if lat is None or lon is None: return None return { "received_at": utc_now(), "source": kind, "latitude": lat, "longitude": lon, "altitude_m": data.get("altitude_m"), "status": data.get("status"), "status_text": data.get("status_text"), "hdop": data.get("hdop"), "hrms_m": data.get("hrms_m"), "vrms_m": data.get("vrms_m"), "satellites_used": data.get("satellites_used"), "raw_nmea": line, } def qv_value(data: dict[str, Any], key: str) -> Any: value = data.get(key) if isinstance(value, dict): return value.get("value") return value def nws_get_json(url: str) -> dict[str, Any]: request = urllib.request.Request( url, headers={ "User-Agent": NWS_USER_AGENT, "Accept": "application/geo+json, application/json", }, ) with urllib.request.urlopen(request, timeout=NWS_TIMEOUT_S) as response: return json.loads(response.read().decode("utf-8")) def fetch_nws_weather(lat: float, lon: float) -> dict[str, Any]: point_url = f"https://api.weather.gov/points/{lat:.6f},{lon:.6f}" point_data = nws_get_json(point_url) point_props = point_data.get("properties") or {} stations_url = point_props.get("observationStations") if not stations_url: raise RuntimeError("NWS did not return observation stations for this location") stations_data = nws_get_json(stations_url) stations = stations_data.get("features") or [] if not stations: raise RuntimeError("NWS did not return any nearby observation stations") station_props = stations[0].get("properties") or {} station_id = station_props.get("stationIdentifier") or str(station_props.get("@id", "")).rstrip("/").split("/")[-1] if not station_id: raise RuntimeError("NWS station record did not include a station identifier") observation_url = f"https://api.weather.gov/stations/{urllib.parse.quote(station_id)}/observations/latest" observation_data = nws_get_json(observation_url) observation_props = observation_data.get("properties") or {} return { "record_type": "weather", "captured_at": utc_now(), "provider": "NWS", "source_urls": { "point": point_url, "stations": stations_url, "observation": observation_url, }, "location": { "latitude": lat, "longitude": lon, "forecast_office": point_props.get("forecastOffice"), "grid_id": point_props.get("gridId"), "grid_x": point_props.get("gridX"), "grid_y": point_props.get("gridY"), "timezone": point_props.get("timeZone"), }, "station": { "id": station_id, "name": station_props.get("name"), "url": station_props.get("@id"), "timezone": station_props.get("timeZone"), }, "observation": { "timestamp": observation_props.get("timestamp"), "text_description": observation_props.get("textDescription"), "temperature_c": qv_value(observation_props, "temperature"), "dewpoint_c": qv_value(observation_props, "dewpoint"), "relative_humidity_percent": qv_value(observation_props, "relativeHumidity"), "wind_direction_deg": qv_value(observation_props, "windDirection"), "wind_speed_kmh": qv_value(observation_props, "windSpeed"), "wind_gust_kmh": qv_value(observation_props, "windGust"), "barometric_pressure_pa": qv_value(observation_props, "barometricPressure"), "sea_level_pressure_pa": qv_value(observation_props, "seaLevelPressure"), "visibility_m": qv_value(observation_props, "visibility"), "precipitation_last_hour_m": qv_value(observation_props, "precipitationLastHour"), }, } class PositionLogger: def __init__(self, log_dir: Path) -> None: self.log_dir = log_dir self.active = False self.path: Path | None = None self.file: Any = None self.points: list[dict[str, Any]] = [] self.metrics: dict[str, Any] = {"count": 0} self.notes = "" self.weather: dict[str, Any] | None = None self.weather_attempted = False @property def filename(self) -> str | None: return self.path.name if self.path else None def start(self, notes: str = "") -> dict[str, Any]: if self.active: return self.status(include_points=True) self.log_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") self.path = self.log_dir / f"nmea_{timestamp}.jsonl" self.file = self.path.open("a", encoding="utf-8") self.points = [] self.metrics = {"count": 0} self.notes = notes.strip() self.weather = None self.weather_attempted = False self.active = True self._write({"record_type": "session", "started_at": utc_now(), "filename": self.filename, "notes": self.notes}) return self.status(include_points=True) def stop(self) -> dict[str, Any]: if not self.active: return self.status(include_points=True) self.metrics = calculate_position_metrics(self.points) self._write({"record_type": "session_end", "stopped_at": utc_now(), "notes": self.notes, "metrics": self.metrics}) if self.file: self.file.close() self.file = None self.active = False return self.status(include_points=True) def record(self, line: str, kind: str, data: dict[str, Any] | None) -> list[dict[str, Any]]: if not self.active or data is None: return [] point = make_log_point(line, kind, data) if point is None: return [] point["index"] = len(self.points) + 1 self.points.append(point) self._write({"record_type": "point", "point": point}) self.metrics = calculate_position_metrics(self.points) events = [ { "type": "measurement_point", "file": self.filename, "count": len(self.points), "point": point, "metrics": self.metrics, } ] if len(self.points) % 10 == 0: events.append( { "type": "measurement_metrics", "file": self.filename, "count": len(self.points), "metrics": self.metrics, } ) return events async def capture_weather_if_needed(self, point: dict[str, Any]) -> dict[str, Any] | None: if not self.active or self.weather_attempted or point.get("latitude") is None or point.get("longitude") is None: return None self.weather_attempted = True target_path = self.path target_filename = self.filename try: weather = await asyncio.to_thread(fetch_nws_weather, float(point["latitude"]), float(point["longitude"])) except (OSError, RuntimeError, urllib.error.URLError, ValueError) as exc: weather = { "record_type": "weather_error", "captured_at": utc_now(), "provider": "NWS", "location": { "latitude": point.get("latitude"), "longitude": point.get("longitude"), }, "error": str(exc), } if target_path: self._write_to_path(target_path, weather) if self.path == target_path: self.weather = weather return { "type": "measurement_weather", "file": target_filename, "weather": weather, } async def capture_weather_for_log(self, filename: str, point: dict[str, Any]) -> dict[str, Any]: path = self._resolve_log_path(filename) try: weather = await asyncio.to_thread(fetch_nws_weather, float(point["latitude"]), float(point["longitude"])) except (OSError, RuntimeError, urllib.error.URLError, ValueError) as exc: weather = { "record_type": "weather_error", "captured_at": utc_now(), "provider": "NWS", "location": { "latitude": point.get("latitude"), "longitude": point.get("longitude"), }, "error": str(exc), } self._write_to_path(path, weather) if self.path == path: self.weather = weather return weather def status(self, include_points: bool = False) -> dict[str, Any]: if self.points and (self.metrics.get("count") != len(self.points) or "receiver_hrms_rms_m" not in self.metrics): self.metrics = calculate_position_metrics(self.points) status = { "active": self.active, "file": self.filename, "count": len(self.points), "metrics": self.metrics, "notes": self.notes, "weather": self.weather, } if include_points: status["points"] = self.points return status def logs(self) -> list[dict[str, Any]]: self.log_dir.mkdir(parents=True, exist_ok=True) logs = [] for path in sorted(self.log_dir.glob("nmea_*.jsonl"), key=lambda item: item.stat().st_mtime, reverse=True): stat = path.stat() logs.append( { "filename": path.name, "size_bytes": stat.st_size, "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), } ) return logs def load(self, filename: str, fixed_only: bool = False) -> dict[str, Any]: path = self._resolve_log_path(filename) points: list[dict[str, Any]] = [] notes = "" weather: dict[str, Any] | None = None for line in path.read_text(encoding="utf-8").splitlines(): try: record = json.loads(line) except json.JSONDecodeError: continue if record.get("record_type") == "session": notes = str(record.get("notes") or "") if record.get("record_type") in {"weather", "weather_error"}: weather = record if record.get("record_type") == "point" and isinstance(record.get("point"), dict): point = record["point"] point.setdefault("index", len(points) + 1) points.append(point) selected_points = rtk_fixed_streak_points(points) if fixed_only else points return { "file": path.name, "points": selected_points, "count": len(selected_points), "total_count": len(points), "fixed_only": fixed_only, "fixed_streak_length": RTK_FIXED_STREAK_LENGTH if fixed_only else None, "notes": notes, "weather": weather, "metrics": calculate_position_metrics(selected_points), } def _write(self, record: dict[str, Any]) -> None: if not self.file: return self.file.write(json.dumps(record, separators=(",", ":")) + "\n") self.file.flush() def _write_to_path(self, path: Path, record: dict[str, Any]) -> None: if self.path == path and self.file: self._write(record) return with path.open("a", encoding="utf-8") as log_file: log_file.write(json.dumps(record, separators=(",", ":")) + "\n") def _resolve_log_path(self, filename: str) -> Path: path = self.log_dir / Path(filename).name if path.parent.resolve() != self.log_dir.resolve() or not path.exists() or path.suffix != ".jsonl": raise FileNotFoundError(filename) return path position_logger = PositionLogger(LOG_DIR) class NTRIPClient: """NTRIP client for receiving RTCM corrections.""" def __init__(self, hub: Hub) -> None: self.hub = hub self.connected = False self.socket: socket.socket | None = None self.thread: threading.Thread | None = None self.stop_event = threading.Event() self.parser = RTCMParser() self.start_time: float | None = None self.config: dict[str, Any] = {} self.rover_position: dict[str, float] | None = None self.loop: asyncio.AbstractEventLoop | None = None def attach_loop(self, loop: asyncio.AbstractEventLoop) -> None: self.loop = loop def is_connected(self) -> bool: return self.connected def _build_gga(self, lat: float, lon: float, alt: float) -> bytes: """Build NMEA GGA sentence for NTRIP.""" now_utc = datetime.now(timezone.utc).strftime("%H%M%S") # Convert to NMEA format lat_hemi = "N" if lat >= 0 else "S" lat_abs = abs(lat) lat_deg = int(lat_abs) lat_min = (lat_abs - lat_deg) * 60.0 lat_str = f"{lat_deg:02d}{lat_min:07.4f}" lon_hemi = "E" if lon >= 0 else "W" lon_abs = abs(lon) lon_deg = int(lon_abs) lon_min = (lon_abs - lon_deg) * 60.0 lon_str = f"{lon_deg:03d}{lon_min:07.4f}" fields = [ "GPGGA", now_utc, lat_str, lat_hemi, lon_str, lon_hemi, "1", # GPS fix "12", # Number of satellites "1.0", # HDOP f"{alt:.1f}", "M", "", "M", "", "", ] core = ",".join(fields) checksum = 0 for char in core: checksum ^= ord(char) sentence = f"${core}*{checksum:02X}\r\n" return sentence.encode("ascii") def _make_ntrip_request(self, host: str, port: int, mount: str, user: str, password: str) -> bytes: """Create NTRIP v2 HTTP request.""" auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") req = ( f"GET /{mount} HTTP/1.1\r\n" f"Host: {host}:{port}\r\n" f"Ntrip-Version: Ntrip/2.0\r\n" f"User-Agent: maglink-tester/1.0\r\n" f"Connection: close\r\n" f"Authorization: Basic {auth}\r\n\r\n" ) return req.encode("ascii") def _broadcast_sync(self, event: dict[str, Any]) -> None: """Broadcast event from worker thread to websocket clients.""" if self.loop: asyncio.run_coroutine_threadsafe(self.hub.broadcast(event), self.loop) def _worker(self) -> None: """Worker thread for NTRIP connection.""" try: host = self.config["host"] port = self.config["port"] mountpoint = self.config["mountpoint"] username = self.config["username"] password = self.config["password"] lat = self.config["latitude"] lon = self.config["longitude"] alt = self.config["altitude"] self._broadcast_sync({"type": "ntrip_status", "status": "connecting", "message": f"Connecting to {host}:{port}/{mountpoint}"}) # Connect to caster self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.settimeout(30) self.socket.connect((host, port)) # Send NTRIP request self.socket.sendall(self._make_ntrip_request(host, port, mountpoint, username, password)) # Read HTTP response headers header = b"" while b"\r\n\r\n" not in header: chunk = self.socket.recv(1) if not chunk: raise ConnectionError("Caster closed before headers received") header += chunk header_text = header.decode("iso-8859-1", errors="replace") if "200 OK" not in header_text: raise ConnectionError(f"NTRIP connection failed:\n{header_text}") self.connected = True self.start_time = time.monotonic() self._broadcast_sync({"type": "ntrip_status", "status": "connected", "message": "Connected to NTRIP caster"}) # Start GGA sender thread def gga_sender(): next_send = 0 while not self.stop_event.is_set(): now = time.monotonic() if now >= next_send: gga = self._build_gga(lat, lon, alt) try: self.socket.sendall(gga) self._broadcast_sync({"type": "ntrip_gga", "message": "Sent position to caster"}) except Exception: break next_send = now + 10 # Send every 10 seconds time.sleep(0.5) gga_thread = threading.Thread(target=gga_sender, daemon=True) gga_thread.start() # Main receive loop while not self.stop_event.is_set(): data = self.socket.recv(4096) if not data: raise ConnectionError("Caster closed connection") # Parse RTCM messages messages = self.parser.parse_messages(data) # Broadcast message details for msg in messages: # Calculate baseline if base station position is available if msg.get("base_position") and self.rover_position: baseline_m = haversine_m( self.rover_position["latitude"], self.rover_position["longitude"], msg["base_position"]["latitude"], msg["base_position"]["longitude"] ) msg["baseline_m"] = baseline_m self._broadcast_sync({"type": "ntrip_rtcm", "message": msg}) # Broadcast stats periodically if self.parser.message_count % 10 == 0: stats = self.parser.get_stats() elapsed = time.monotonic() - self.start_time if self.start_time else 0 if elapsed > 0: stats["bytes_per_hour"] = int(stats["total_bytes"] / elapsed * 3600) self._broadcast_sync({"type": "ntrip_stats", "stats": stats}) except Exception as e: self._broadcast_sync({"type": "ntrip_status", "status": "error", "message": str(e)}) finally: self.connected = False if self.socket: try: self.socket.close() except Exception: pass self._broadcast_sync({"type": "ntrip_status", "status": "disconnected", "message": "Disconnected from NTRIP caster"}) def connect(self, config: dict[str, Any]) -> dict[str, Any]: """Start NTRIP connection.""" if self.connected: return {"error": "Already connected"} self.config = config self.stop_event.clear() self.parser = RTCMParser() self.thread = threading.Thread(target=self._worker, daemon=True) self.thread.start() return {"status": "connecting"} def disconnect(self) -> dict[str, Any]: """Stop NTRIP connection.""" if not self.connected and not self.thread: return {"status": "not_connected"} self.stop_event.set() if self.socket: try: self.socket.close() except Exception: pass if self.thread: self.thread.join(timeout=2) self.connected = False return {"status": "disconnected"} def update_rover_position(self, lat: float, lon: float, alt: float | None = None) -> None: """Update rover position for baseline calculation.""" self.rover_position = { "latitude": lat, "longitude": lon, "altitude": alt if alt is not None else 0.0, } def status(self) -> dict[str, Any]: """Get NTRIP client status.""" stats = self.parser.get_stats() if self.start_time and self.connected: elapsed = time.monotonic() - self.start_time stats["bytes_per_hour"] = int(stats["total_bytes"] / elapsed * 3600) if elapsed > 0 else 0 stats["connected_seconds"] = int(elapsed) return { "connected": self.connected, "config": self.config, "stats": stats, "rover_position": self.rover_position, } class BleBridge: def __init__(self, hub: Hub) -> None: self.hub = hub self.client: BleakClient | None = None self.address: str | None = None self.name: str | None = None self.tx_char: str | None = None self.rx_char: str | None = None self.services: list[dict[str, Any]] = [] self.buffer = "" self.loop: asyncio.AbstractEventLoop | None = None def attach_loop(self, loop: asyncio.AbstractEventLoop) -> None: self.loop = loop def is_connected(self) -> bool: return bool(self.client and self.client.is_connected) async def scan(self, timeout: float) -> list[dict[str, Any]]: devices = await BleakScanner.discover(timeout=timeout) results = [] for device in devices: results.append( { "address": device.address, "name": device.name or "(unnamed)", "rssi": getattr(device, "rssi", None), "details": str(getattr(device, "details", "")), } ) return sorted(results, key=lambda item: ((item["name"] or "").lower(), item["address"])) async def connect(self, address: str, tx_char: str | None = None, rx_char: str | None = None) -> dict[str, Any]: await self.disconnect() self.address = address self.client = BleakClient(address, disconnected_callback=self._on_disconnect) await self.hub.broadcast({"type": "status", "message": f"Connecting to {address}"}) await self.client.connect(timeout=20.0) self.services = await self._read_services() self.tx_char, self.rx_char = self._choose_characteristics(tx_char, rx_char) if self.rx_char: await self.client.start_notify(self.rx_char, self._on_notify) await self.hub.broadcast( { "type": "connection", "connected": True, "address": self.address, "tx_char": self.tx_char, "rx_char": self.rx_char, "services": self.services, } ) return self.status() async def set_characteristics(self, tx_char: str | None, rx_char: str | None) -> dict[str, Any]: if not self.client or not self.client.is_connected: raise RuntimeError("Not connected") old_rx = self.rx_char if old_rx and old_rx != rx_char: try: await self.client.stop_notify(old_rx) except Exception: pass self.tx_char = clean_uuid(tx_char) self.rx_char = clean_uuid(rx_char) if self.rx_char and self.rx_char != old_rx: await self.client.start_notify(self.rx_char, self._on_notify) await self.hub.broadcast({"type": "connection", "connected": True, "tx_char": self.tx_char, "rx_char": self.rx_char}) return self.status() async def disconnect(self) -> None: if self.client: try: if self.rx_char and self.client.is_connected: await self.client.stop_notify(self.rx_char) except Exception: pass try: if self.client.is_connected: await self.client.disconnect() finally: self.client = None self.tx_char = None self.rx_char = None self.buffer = "" async def send(self, command: str, append_crlf: bool = True, response: bool = False) -> dict[str, Any]: if not self.client or not self.client.is_connected: raise RuntimeError("Not connected") if not self.tx_char: raise RuntimeError("No writable TX characteristic selected") payload = command if append_crlf and not payload.endswith("\r\n"): payload += "\r\n" await self.client.write_gatt_char(self.tx_char, payload.encode("utf-8"), response=response) await self.hub.broadcast({"type": "tx", "text": payload.replace("\r", "\\r").replace("\n", "\\n")}) return {"sent": command, "bytes": len(payload.encode("utf-8")), "tx_char": self.tx_char} def status(self) -> dict[str, Any]: return { "connected": self.is_connected(), "address": self.address, "tx_char": self.tx_char, "rx_char": self.rx_char, "services": self.services, } async def _read_services(self) -> list[dict[str, Any]]: if not self.client: return [] try: services = await self.client.get_services() except AttributeError: services = self.client.services parsed: list[dict[str, Any]] = [] for service in services: parsed.append( { "uuid": clean_uuid(service.uuid), "description": service.description, "characteristics": [ { "uuid": clean_uuid(char.uuid), "description": char.description, "properties": list(char.properties), } for char in service.characteristics ], } ) return parsed def _choose_characteristics(self, tx_char: str | None, rx_char: str | None) -> tuple[str | None, str | None]: requested_tx = clean_uuid(tx_char) requested_rx = clean_uuid(rx_char) all_chars = [char for service in self.services for char in service["characteristics"]] uuids = {char["uuid"] for char in all_chars} chosen_tx = requested_tx if requested_tx in uuids else None chosen_rx = requested_rx if requested_rx in uuids else None if NUS_RX_WRITE in uuids: chosen_tx = chosen_tx or NUS_RX_WRITE if NUS_TX_NOTIFY in uuids: chosen_rx = chosen_rx or NUS_TX_NOTIFY if not chosen_tx or not chosen_rx: for service in self.services: writes = [c for c in service["characteristics"] if "write" in c["properties"] or "write-without-response" in c["properties"]] notifies = [c for c in service["characteristics"] if "notify" in c["properties"] or "indicate" in c["properties"]] if writes and notifies: chosen_tx = chosen_tx or writes[0]["uuid"] chosen_rx = chosen_rx or notifies[0]["uuid"] break if not chosen_tx: writable = [c for c in all_chars if "write" in c["properties"] or "write-without-response" in c["properties"]] chosen_tx = writable[0]["uuid"] if writable else None if not chosen_rx: notifying = [c for c in all_chars if "notify" in c["properties"] or "indicate" in c["properties"]] chosen_rx = notifying[0]["uuid"] if notifying else None return chosen_tx, chosen_rx def _on_disconnect(self, _client: BleakClient) -> None: if self.loop: self.loop.call_soon_threadsafe( lambda: asyncio.create_task( self.hub.broadcast({"type": "connection", "connected": False, "message": "BLE device disconnected"}) ) ) def _on_notify(self, _sender: int | str, data: bytearray) -> None: text = bytes(data).decode("utf-8", errors="replace") if self.loop: self.loop.call_soon_threadsafe(lambda: asyncio.create_task(self._handle_rx(text))) async def _handle_rx(self, text: str) -> None: await self.hub.broadcast({"type": "rx", "text": text}) self.buffer += text self.buffer = self.buffer[-8192:] while "\n" in self.buffer: line, self.buffer = self.buffer.split("\n", 1) line = line.rstrip("\r").strip() if not line: continue parsed = parse_line(line) event: dict[str, Any] = {"type": "line", "line": line, "kind": parsed.kind} if parsed.checksum_ok is not None: event["checksum_ok"] = parsed.checksum_ok if parsed.data is not None: event["data"] = parsed.data await self.hub.broadcast(event) for measurement_event in position_logger.record(line, parsed.kind, parsed.data): await self.hub.broadcast(measurement_event) if measurement_event["type"] == "measurement_point": weather_event = await position_logger.capture_weather_if_needed(measurement_event["point"]) if weather_event: await self.hub.broadcast(weather_event) # Update NTRIP rover position if we have position data if parsed.data and "latitude" in parsed.data and "longitude" in parsed.data: ntrip.update_rover_position( parsed.data["latitude"], parsed.data["longitude"], parsed.data.get("altitude_m") ) ble = BleBridge(hub) ntrip = NTRIPClient(hub) app = FastAPI(title="H11 RTK BLE Command Console") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost", "http://127.0.0.1", "http://localhost:8000", "http://127.0.0.1:8000"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.mount("/static", StaticFiles(directory=APP_DIR / "static"), name="static") @app.on_event("startup") async def startup() -> None: loop = asyncio.get_running_loop() ble.attach_loop(loop) ntrip.attach_loop(loop) @app.on_event("shutdown") async def shutdown() -> None: await ble.disconnect() ntrip.disconnect() @app.get("/", response_class=HTMLResponse) async def index() -> str: return (APP_DIR / "static" / "index.html").read_text(encoding="utf-8") @app.get("/api/commands") async def commands() -> dict[str, Any]: return {"commands": COMMANDS} @app.get("/api/status") async def status() -> dict[str, Any]: return ble.status() @app.post("/api/scan") async def scan(req: ScanRequest) -> dict[str, Any]: return {"devices": await ble.scan(req.timeout)} @app.post("/api/connect") async def connect(req: ConnectRequest) -> dict[str, Any]: return await ble.connect(req.address, req.tx_char, req.rx_char) @app.post("/api/characteristics") async def set_characteristics(req: CharacteristicRequest) -> dict[str, Any]: return await ble.set_characteristics(req.tx_char, req.rx_char) @app.post("/api/disconnect") async def disconnect() -> dict[str, Any]: await ble.disconnect() await hub.broadcast({"type": "connection", "connected": False, "message": "Disconnected"}) return {"connected": False} @app.post("/api/send") async def send(req: SendRequest) -> dict[str, Any]: return await ble.send(req.command, req.append_crlf, req.response) @app.post("/api/build-command") async def build_command(req: BuildCommandRequest) -> dict[str, Any]: command = format_command(req.name, req.action, req.params) return {"command": command} @app.get("/api/measure/status") async def measurement_status() -> dict[str, Any]: return position_logger.status(include_points=True) @app.post("/api/measure/start") async def measurement_start(req: MeasurementStartRequest) -> dict[str, Any]: status = position_logger.start(req.notes) await hub.broadcast({"type": "measurement_status", **status}) return status @app.post("/api/measure/stop") async def measurement_stop() -> dict[str, Any]: if position_logger.active and position_logger.points and not position_logger.weather_attempted: weather_event = await position_logger.capture_weather_if_needed(position_logger.points[0]) if weather_event: await hub.broadcast(weather_event) status = position_logger.stop() await hub.broadcast({"type": "measurement_status", **status}) await hub.broadcast({"type": "measurement_metrics", "file": status["file"], "count": status["count"], "metrics": status["metrics"]}) return status @app.get("/api/measure/logs") async def measurement_logs() -> dict[str, Any]: return {"logs": position_logger.logs()} @app.get("/api/measure/logs/{filename}") async def measurement_log(filename: str, fixed_only: bool = False) -> dict[str, Any]: try: log = position_logger.load(filename, fixed_only=fixed_only) if log["weather"] is None and log["points"]: log["weather"] = await position_logger.capture_weather_for_log(filename, log["points"][0]) return log except FileNotFoundError: raise HTTPException(status_code=404, detail="Log not found") from None @app.get("/api/ntrip/status") async def ntrip_status() -> dict[str, Any]: return ntrip.status() @app.post("/api/ntrip/connect") async def ntrip_connect(req: NTRIPConnectRequest) -> dict[str, Any]: config = { "host": req.host or NTRIP_CASTER_HOST, "port": req.port or NTRIP_CASTER_PORT, "mountpoint": req.mountpoint or NTRIP_MOUNTPOINT, "username": req.username or NTRIP_USERNAME, "password": req.password or NTRIP_PASSWORD, "latitude": req.latitude or NTRIP_LAT, "longitude": req.longitude or NTRIP_LON, "altitude": req.altitude or NTRIP_ALT, } return ntrip.connect(config) @app.post("/api/ntrip/disconnect") async def ntrip_disconnect() -> dict[str, Any]: return ntrip.disconnect() @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket) -> None: await hub.connect(websocket) try: while True: message = await websocket.receive_text() if message == "ping": await websocket.send_text(json.dumps({"type": "pong", "ts": utc_now()})) except WebSocketDisconnect: hub.disconnect(websocket) except Exception: hub.disconnect(websocket) @app.get("/health") async def health() -> dict[str, str]: return {"ok": "true"} if __name__ == "__main__": import uvicorn uvicorn.run("app:app", host="127.0.0.1", port=8100, reload=False)