Files
maglink-console/app.py
brentperteet 5703c05c1d Initial commit
2026-06-24 11:12:44 -05:00

1487 lines
56 KiB
Python

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)