1487 lines
56 KiB
Python
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)
|