Initial commit

This commit is contained in:
brentperteet
2026-06-24 11:12:44 -05:00
commit 5703c05c1d
30 changed files with 8149 additions and 0 deletions

453
ntrip/client.py Normal file
View File

@@ -0,0 +1,453 @@
#!/usr/bin/env python3
"""
Simple NTRIP client to pull RTCM from a CORS/RTK caster and inject to a GNSS receiver.
- Connects to caster with HTTP Basic Auth (NTRIP v2 headers).
- Sends NMEA GGA immediately and every GGA_INTERVAL seconds.
- Writes RTCM bytes to a serial port (or optional TCP out).
"""
import base64
import os
import socket
import sys
import time
import threading
from datetime import datetime, timezone
from typing import Optional
try:
import serial # pip install pyserial
except ImportError:
serial = None
# ========= USER SETTINGS =========
# --- Caster / NTRIP source ---
CASTER_HOST = "truertk.pointonenav.com" # e.g. "12.34.56.78"
CASTER_PORT = 2101 # e.g. 2101
MOUNTPOINT = "AUTO" # e.g. "RTCM3"
USERNAME = "9t7fwfbm57"
PASSWORD = "96m7bec9g8"
# --- Output to GNSS receiver ---
USE_SERIAL_OUT = False
SERIAL_PORT = "/dev/tty.ML-NA001-250079" # Windows: "COM5"
SERIAL_BAUD = 115200
# Optional: forward RTCM to TCP instead of serial (set USE_SERIAL_OUT=False)
USE_TCP_OUT = False
TCP_OUT_HOST = "127.0.0.1"
TCP_OUT_PORT = 2102
# --- GGA configuration ---
SEND_GGA = True
GGA_INTERVAL_SEC = 10 # caster-friendly: 515 seconds typical
# If you have a rough position, put it here (WGS84):
GGA_LAT_DEG = 36.1140884 # positive N, negative S
GGA_LON_DEG = -97.0880663 # positive E, negative W
GGA_ALT_M = 390.0 # orthometric (approx OK)
# --- Misc/retry ---
RECV_BUF = 4096
RECONNECT_DELAY_S = 5
SOCK_TIMEOUT_S = 30
USER_AGENT = "NTRIP pyclient/1.0"
# --- Debug ---
DEBUG_RTCM = True # Show RTCM message stats
PARSE_NMEA = True # Parse NMEA from receiver (read from serial)
USE_RECEIVER_POS = True # Use receiver's actual position for GGA to caster
DEBUG_ACCURACY = True # Show receiver accuracy info
# =================================
def nmea_checksum(sentence_no_dollar: str) -> str:
csum = 0
for ch in sentence_no_dollar:
csum ^= ord(ch)
return f"{csum:02X}"
def format_lat_lon(lat_deg: float, lon_deg: float):
"""
Convert signed decimal degrees to NMEA ddmm.mmmm, dddmm.mmmm and hemispheres.
"""
# Latitude
lat_hemi = "N" if lat_deg >= 0 else "S"
lat_abs = abs(lat_deg)
lat_deg_i = int(lat_abs)
lat_min = (lat_abs - lat_deg_i) * 60.0
lat_str = f"{lat_deg_i:02d}{lat_min:07.4f}"
# Longitude
lon_hemi = "E" if lon_deg >= 0 else "W"
lon_abs = abs(lon_deg)
lon_deg_i = int(lon_abs)
lon_min = (lon_abs - lon_deg_i) * 60.0
lon_str = f"{lon_deg_i:03d}{lon_min:07.4f}"
return lat_str, lat_hemi, lon_str, lon_hemi
def parse_nmea_position(lat_nmea: str, lat_dir: str, lon_nmea: str, lon_dir: str):
"""Convert NMEA ddmm.mmmm format to decimal degrees."""
try:
# Latitude: ddmm.mmmm
lat_deg = int(float(lat_nmea) / 100)
lat_min = float(lat_nmea) - (lat_deg * 100)
lat_decimal = lat_deg + (lat_min / 60.0)
if lat_dir == 'S':
lat_decimal = -lat_decimal
# Longitude: dddmm.mmmm
lon_deg = int(float(lon_nmea) / 100)
lon_min = float(lon_nmea) - (lon_deg * 100)
lon_decimal = lon_deg + (lon_min / 60.0)
if lon_dir == 'W':
lon_decimal = -lon_decimal
return lat_decimal, lon_decimal
except (ValueError, ZeroDivisionError):
return None, None
def parse_gga(sentence: str):
"""
Parse NMEA GGA sentence.
Returns dict with position, quality, sats, hdop, altitude.
"""
parts = sentence.split(',')
if len(parts) < 15:
return None
try:
return {
'time': parts[1],
'lat': parts[2],
'lat_dir': parts[3],
'lon': parts[4],
'lon_dir': parts[5],
'quality': int(parts[6]) if parts[6] else 0,
'num_sats': int(parts[7]) if parts[7] else 0,
'hdop': float(parts[8]) if parts[8] else 0.0,
'altitude': float(parts[9]) if parts[9] else 0.0,
}
except (ValueError, IndexError):
return None
def build_gga(lat_deg: float, lon_deg: float, alt_m: float, fix_quality=1, sats=12, hdop=1.0) -> bytes:
"""
Build a minimal NMEA GGA sentence (UTC time now, fix provided). Returns CRLF-terminated bytes.
"""
now = datetime.now(timezone.utc).strftime("%H%M%S")
lat_str, lat_hemi, lon_str, lon_hemi = format_lat_lon(lat_deg, lon_deg)
# GGA fields:
# $GPGGA,time,lat,NS,lon,EW,quality,numSV,HDOP,alt,M,sep,M,diffAge,diffStation
fields = [
"GPGGA",
now,
lat_str, lat_hemi,
lon_str, lon_hemi,
str(fix_quality), # 1 = GPS fix, 4/5 = RTK; for caster seeding 1 is fine
f"{sats:02d}",
f"{hdop:.1f}",
f"{alt_m:.1f}", "M", # altitude + units
"", "M", # geoid separation unknown
"", "", # DGPS age/station
]
core = ",".join(fields)
csum = nmea_checksum(core)
sentence = f"${core}*{csum}\r\n"
return sentence.encode("ascii")
def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes:
auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
# NTRIP v2-style request. Mountpoint must be URL-encoded if it contains special chars; most are simple.
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: {USER_AGENT}\r\n"
f"Connection: close\r\n"
f"Authorization: Basic {auth}\r\n\r\n"
)
return req.encode("ascii")
class RTCMForwarder:
def __init__(self):
self.ser = None
self.tcp_out_sock = None
self.total_bytes = 0
self.msg_count = 0
self.start_time = None
# For tracking receiver position/status
self.latest_gga = None
self.nmea_buffer = ""
def open(self):
self.start_time = time.monotonic()
if USE_SERIAL_OUT:
if serial is None:
raise RuntimeError("pyserial is not installed. Install with: pip install pyserial")
self.ser = serial.Serial(SERIAL_PORT, SERIAL_BAUD, timeout=0)
print(f"[OUT] Serial open {SERIAL_PORT} @ {SERIAL_BAUD}")
elif USE_TCP_OUT:
self.tcp_out_sock = socket.create_connection((TCP_OUT_HOST, TCP_OUT_PORT), timeout=5)
print(f"[OUT] TCP forward connected {TCP_OUT_HOST}:{TCP_OUT_PORT}")
else:
print("[OUT] No output configured; data will be discarded.")
def read_nmea(self):
"""Read and parse NMEA data from the receiver (non-blocking)."""
if not self.ser or not PARSE_NMEA:
return
try:
# Read available data (non-blocking because timeout=0)
if self.ser.in_waiting > 0:
data = self.ser.read(self.ser.in_waiting)
self.nmea_buffer += data.decode('ascii', errors='ignore')
# Process complete NMEA sentences
while '\n' in self.nmea_buffer:
line, self.nmea_buffer = self.nmea_buffer.split('\n', 1)
line = line.strip()
if line.startswith('$') and 'GGA' in line:
gga = parse_gga(line)
if gga and gga['quality'] > 0:
self.latest_gga = gga
if DEBUG_ACCURACY:
self._display_accuracy(gga)
except Exception:
# Don't crash on NMEA parse errors
pass
def _display_accuracy(self, gga):
"""Display receiver accuracy information."""
quality_map = {
0: "Invalid",
1: "GPS (SPS)",
2: "DGPS",
3: "PPS",
4: "RTK Fixed",
5: "RTK Float",
6: "Estimated",
}
quality_str = quality_map.get(gga['quality'], f"Unknown({gga['quality']})")
# Calculate estimated accuracy
uere_map = {
4: 0.01, # RTK Fixed: 1cm
5: 0.3, # RTK Float: 30cm
2: 1.5, # DGPS: 1.5m
1: 5.0, # GPS: 5m
0: 999.0
}
uere = uere_map.get(gga['quality'], 10.0)
accuracy = gga['hdop'] * uere
# Convert position to decimal degrees
lat_dd, lon_dd = parse_nmea_position(gga['lat'], gga['lat_dir'],
gga['lon'], gga['lon_dir'])
indicator = ""
if gga['quality'] == 4:
indicator = " ← RTK FIXED ✓"
elif gga['quality'] == 5:
indicator = " ← RTK FLOAT"
print(f"[RX] {quality_str:12s} | Sats: {gga['num_sats']:2d} | HDOP: {gga['hdop']:4.1f} | "
f"Acc: {accuracy:6.3f}m ({accuracy*100:5.1f}cm){indicator}")
if lat_dd and lon_dd:
print(f" Position: {lat_dd:11.7f}°, {lon_dd:11.7f}° | Alt: {gga['altitude']:6.1f}m")
def get_position(self):
"""Get the latest position from the receiver, or fallback to configured position."""
if USE_RECEIVER_POS and self.latest_gga:
lat_dd, lon_dd = parse_nmea_position(
self.latest_gga['lat'], self.latest_gga['lat_dir'],
self.latest_gga['lon'], self.latest_gga['lon_dir']
)
if lat_dd and lon_dd:
return lat_dd, lon_dd, self.latest_gga['altitude']
# Fallback to configured position
return GGA_LAT_DEG, GGA_LON_DEG, GGA_ALT_M
def write(self, data: bytes):
if not data:
return
# Track statistics
self.total_bytes += len(data)
# Debug: parse and display RTCM messages
if DEBUG_RTCM:
self._debug_rtcm(data)
if self.ser:
self.ser.write(data)
elif self.tcp_out_sock:
try:
self.tcp_out_sock.sendall(data)
except Exception:
# attempt to reconnect once
try:
self.tcp_out_sock.close()
except Exception:
pass
self.tcp_out_sock = socket.create_connection((TCP_OUT_HOST, TCP_OUT_PORT), timeout=5)
self.tcp_out_sock.sendall(data)
# else: discard
def _debug_rtcm(self, data: bytes):
"""Parse and display RTCM3 message info"""
i = 0
while i < len(data):
# RTCM3 messages start with 0xD3
if data[i] == 0xD3 and i + 2 < len(data):
# Parse header: 0xD3 + 2 bytes (6 bits reserved + 10 bits length)
length = ((data[i+1] & 0x03) << 8) | data[i+2]
msg_total_len = 3 + length + 3 # header + payload + CRC
if i + msg_total_len <= len(data) and length >= 3:
# Extract message type (first 12 bits of payload)
msg_type = (data[i+3] << 4) | (data[i+4] >> 4)
self.msg_count += 1
# Calculate bytes per hour
elapsed = time.monotonic() - self.start_time
if elapsed > 0:
bytes_per_hour = int(self.total_bytes / elapsed * 3600)
print(f"[RTCM] Msg #{self.msg_count}: Type {msg_type:4d}, {length:4d} bytes payload, {self.total_bytes:8d} total bytes ({bytes_per_hour:,} bytes/hour)")
else:
print(f"[RTCM] Msg #{self.msg_count}: Type {msg_type:4d}, {length:4d} bytes payload, {self.total_bytes:8d} total bytes")
i += msg_total_len
continue
i += 1
def close(self):
try:
if self.ser:
self.ser.close()
if self.tcp_out_sock:
self.tcp_out_sock.close()
except Exception:
pass
def ntrip_loop():
out = RTCMForwarder()
out.open()
while True:
try:
print(f"[NTRIP] Connecting to {CASTER_HOST}:{CASTER_PORT} /{MOUNTPOINT}")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(SOCK_TIMEOUT_S)
s.connect((CASTER_HOST, CASTER_PORT))
# Send request
s.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD))
# Read HTTP response headers
header = b""
while b"\r\n\r\n" not in header:
chunk = s.recv(1)
if not chunk:
raise ConnectionError("Caster closed before headers were received.")
header += chunk
header_text = header.decode("iso-8859-1", errors="replace")
if "200 OK" not in header_text:
s.close()
raise ConnectionError(f"NTRIP error or mount not found:\n{header_text}")
print("[NTRIP] 200 OK streaming RTCM")
# Thread to send periodic GGA
stop_gga = threading.Event()
def gga_sender():
if not SEND_GGA:
return
# Many casters accept GGA after the HTTP header via the same socket
# (Write NMEA sentences to the socket; they are ignored by HTTP and consumed by caster)
next_send = 0
while not stop_gga.is_set():
now = time.monotonic()
if now >= next_send:
# Get position from receiver or use fallback
lat, lon, alt = out.get_position()
gga = build_gga(lat, lon, alt)
try:
s.sendall(gga)
if USE_RECEIVER_POS and out.latest_gga:
print(f"[GGA→] Sent receiver position to caster")
else:
print(f"[GGA→] Sent fallback position to caster")
except Exception:
break
next_send = now + GGA_INTERVAL_SEC
time.sleep(0.5)
gga_thread = threading.Thread(target=gga_sender, daemon=True)
gga_thread.start()
# Main receive loop
last_data_time = time.monotonic()
while True:
# Read NMEA from receiver (non-blocking)
out.read_nmea()
data = s.recv(RECV_BUF)
if not data:
raise ConnectionError("Caster closed the connection.")
last_data_time = time.monotonic()
out.write(data)
# Simple idle watchdog
if time.monotonic() - last_data_time > SOCK_TIMEOUT_S:
raise TimeoutError("No data from caster.")
except KeyboardInterrupt:
print("\n[EXIT] Interrupted by user.")
out.close()
try:
s.close()
except Exception:
pass
sys.exit(0)
except Exception as e:
print(f"[WARN] {e}")
try:
s.close()
except Exception:
pass
print(f"[NTRIP] Reconnecting in {RECONNECT_DELAY_S}s…")
time.sleep(RECONNECT_DELAY_S)
continue
if __name__ == "__main__":
# Basic sanity checks
if not CASTER_HOST or not MOUNTPOINT or not USERNAME:
print("Please fill in CASTER_HOST, MOUNTPOINT, USERNAME, PASSWORD at the top of this script.")
sys.exit(1)
if USE_SERIAL_OUT is False and USE_TCP_OUT is False:
print("No output path enabled. Set USE_SERIAL_OUT=True or USE_TCP_OUT=True.")
# continue anyway (discards data)
ntrip_loop()