Initial commit
This commit is contained in:
288
rtcm_208_decoder.py
Normal file
288
rtcm_208_decoder.py
Normal file
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Decoder for RTCM message 208 - appears to be ASCII/text based.
|
||||
Captures and displays message 208 content in multiple formats.
|
||||
"""
|
||||
import base64
|
||||
import socket
|
||||
import time
|
||||
import csv
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
# NTRIP configuration
|
||||
CASTER_HOST = "truertk.pointonenav.com"
|
||||
CASTER_PORT = 2101
|
||||
MOUNTPOINT = "AUTO"
|
||||
USERNAME = "9t7fwfbm57"
|
||||
PASSWORD = "96m7bec9g8"
|
||||
|
||||
LAT = 36.1140884
|
||||
LON = -97.0880663
|
||||
ALT = 390.0
|
||||
|
||||
|
||||
def build_gga(lat: float, lon: float, alt: float) -> bytes:
|
||||
"""Build NMEA GGA sentence."""
|
||||
now_utc = datetime.now(timezone.utc).strftime("%H%M%S")
|
||||
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", "12", "1.0", f"{alt:.1f}", "M", "", "M", "", ""]
|
||||
core = ",".join(fields)
|
||||
checksum = 0
|
||||
for char in core:
|
||||
checksum ^= ord(char)
|
||||
return f"${core}*{checksum:02X}\r\n".encode("ascii")
|
||||
|
||||
|
||||
def make_ntrip_request(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: RTCM-208-Decoder/1.0\r\n"
|
||||
f"Connection: close\r\n"
|
||||
f"Authorization: Basic {auth}\r\n\r\n"
|
||||
)
|
||||
return req.encode("ascii")
|
||||
|
||||
|
||||
def decode_message_208(data: bytes, msg_num: int) -> dict:
|
||||
"""Decode message 208 and extract fields."""
|
||||
result = {
|
||||
"message_number": msg_num,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"length_bytes": len(data),
|
||||
"raw_hex": data.hex(),
|
||||
}
|
||||
|
||||
# Try decoding as ASCII text
|
||||
try:
|
||||
text = data.decode('ascii')
|
||||
result["text_ascii"] = text
|
||||
result["text_repr"] = repr(text)
|
||||
except:
|
||||
result["text_ascii"] = None
|
||||
|
||||
# Try decoding as UTF-8
|
||||
try:
|
||||
text_utf8 = data.decode('utf-8')
|
||||
result["text_utf8"] = text_utf8
|
||||
except:
|
||||
result["text_utf8"] = None
|
||||
|
||||
# Check if it looks like comma-separated values
|
||||
if result.get("text_ascii"):
|
||||
text = result["text_ascii"].strip()
|
||||
if "," in text or ";" in text:
|
||||
result["appears_csv"] = True
|
||||
# Try splitting by common delimiters
|
||||
if "," in text:
|
||||
parts = text.split(",")
|
||||
result["csv_fields"] = parts
|
||||
result["csv_field_count"] = len(parts)
|
||||
elif ";" in text:
|
||||
parts = text.split(";")
|
||||
result["csv_fields"] = parts
|
||||
result["csv_field_count"] = len(parts)
|
||||
|
||||
# Check if it starts with specific markers
|
||||
if len(data) >= 2:
|
||||
if data[0:2] == b'\r\n':
|
||||
result["starts_with_crlf"] = True
|
||||
|
||||
# Look for printable characters percentage
|
||||
printable_count = sum(1 for b in data if 32 <= b < 127 or b in [9, 10, 13])
|
||||
result["printable_percent"] = (printable_count / len(data) * 100) if len(data) > 0 else 0
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
print(f"RTCM Message 208 Decoder")
|
||||
print(f"=" * 80)
|
||||
print(f"Caster: {CASTER_HOST}:{CASTER_PORT}/{MOUNTPOINT}")
|
||||
print(f"Capturing message 208 for 60 seconds...")
|
||||
print(f"=" * 80)
|
||||
print()
|
||||
|
||||
messages_208 = []
|
||||
last_gga_time = 0
|
||||
start_time = time.monotonic()
|
||||
total_messages = 0
|
||||
msg_208_count = 0
|
||||
|
||||
try:
|
||||
# Connect
|
||||
print("Connecting...")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(30)
|
||||
sock.connect((CASTER_HOST, CASTER_PORT))
|
||||
sock.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD))
|
||||
|
||||
# Read headers
|
||||
header = b""
|
||||
while b"\r\n\r\n" not in header:
|
||||
chunk = sock.recv(1)
|
||||
if not chunk:
|
||||
print("ERROR: Connection closed")
|
||||
return
|
||||
header += chunk
|
||||
|
||||
if "200 OK" not in header.decode("iso-8859-1", errors="replace"):
|
||||
print("ERROR: Connection failed")
|
||||
return
|
||||
|
||||
print("Connected! Capturing messages...\n")
|
||||
|
||||
# Receive loop
|
||||
while time.monotonic() - start_time < 60:
|
||||
now = time.monotonic()
|
||||
|
||||
# Send GGA every 10 seconds
|
||||
if now - last_gga_time >= 10:
|
||||
sock.sendall(build_gga(LAT, LON, ALT))
|
||||
elapsed = int(now - start_time)
|
||||
print(f"[{elapsed:3d}s] Sent GGA | Total: {total_messages}, Msg 208: {msg_208_count}")
|
||||
last_gga_time = now
|
||||
|
||||
# Receive data
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
print("Connection closed")
|
||||
break
|
||||
|
||||
# Parse messages
|
||||
i = 0
|
||||
while i < len(data):
|
||||
if data[i] == 0xD3 and i + 2 < len(data):
|
||||
length = ((data[i+1] & 0x03) << 8) | data[i+2]
|
||||
msg_total_len = 3 + length + 3
|
||||
|
||||
if i + msg_total_len <= len(data) and length >= 3:
|
||||
msg_type = (data[i+3] << 4) | (data[i+4] >> 4)
|
||||
msg_data = data[i+3:i+3+length]
|
||||
total_messages += 1
|
||||
|
||||
if msg_type == 208:
|
||||
msg_208_count += 1
|
||||
decoded = decode_message_208(msg_data, msg_208_count)
|
||||
messages_208.append(decoded)
|
||||
|
||||
# Show first few messages
|
||||
if msg_208_count <= 5:
|
||||
print(f"\n{'─' * 80}")
|
||||
print(f"Message 208 #{msg_208_count} - {length} bytes - {decoded['printable_percent']:.0f}% printable")
|
||||
print(f"{'─' * 80}")
|
||||
if decoded.get("text_ascii"):
|
||||
print(f"ASCII: {decoded['text_repr']}")
|
||||
if decoded.get("appears_csv"):
|
||||
print(f"CSV Fields ({decoded['csv_field_count']}): {decoded['csv_fields']}")
|
||||
else:
|
||||
print(f"Hex: {decoded['raw_hex'][:100]}...")
|
||||
|
||||
i += msg_total_len
|
||||
continue
|
||||
i += 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nInterrupted by user")
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
if 'sock' in locals():
|
||||
try:
|
||||
sock.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Analysis
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f"ANALYSIS")
|
||||
print(f"{'=' * 80}")
|
||||
print(f"Total messages: {total_messages}")
|
||||
print(f"Message 208 captured: {msg_208_count}")
|
||||
|
||||
if messages_208:
|
||||
# Check if they're all ASCII
|
||||
all_ascii = all(m.get("text_ascii") is not None for m in messages_208)
|
||||
print(f"\nAll message 208 are ASCII text: {all_ascii}")
|
||||
|
||||
# Check for CSV pattern
|
||||
csv_count = sum(1 for m in messages_208 if m.get("appears_csv"))
|
||||
print(f"Messages that appear to be CSV: {csv_count}/{msg_208_count}")
|
||||
|
||||
# Show field count distribution
|
||||
if csv_count > 0:
|
||||
field_counts = [m.get("csv_field_count", 0) for m in messages_208 if m.get("appears_csv")]
|
||||
print(f"CSV field counts: min={min(field_counts)}, max={max(field_counts)}, mode={max(set(field_counts), key=field_counts.count)}")
|
||||
|
||||
# Export to CSV
|
||||
csv_filename = "rtcm_208_messages.csv"
|
||||
print(f"\nExporting to CSV: {csv_filename}")
|
||||
|
||||
with open(csv_filename, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
fieldnames = ["message_number", "timestamp", "length_bytes", "printable_percent",
|
||||
"text_ascii", "text_repr", "csv_field_count", "raw_hex"]
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for msg in messages_208:
|
||||
writer.writerow(msg)
|
||||
|
||||
print(f"✓ Exported {len(messages_208)} messages to {csv_filename}")
|
||||
|
||||
# If CSV-like, create a parsed CSV
|
||||
if csv_count > 0:
|
||||
csv_parsed_filename = "rtcm_208_parsed.csv"
|
||||
print(f"\nExporting parsed CSV fields: {csv_parsed_filename}")
|
||||
|
||||
# Find max field count
|
||||
max_fields = max(m.get("csv_field_count", 0) for m in messages_208 if m.get("appears_csv"))
|
||||
|
||||
with open(csv_parsed_filename, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
fieldnames = ["message_number", "timestamp"] + [f"field_{i}" for i in range(max_fields)]
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for msg in messages_208:
|
||||
if msg.get("appears_csv") and msg.get("csv_fields"):
|
||||
row = {
|
||||
"message_number": msg["message_number"],
|
||||
"timestamp": msg["timestamp"]
|
||||
}
|
||||
for i, field in enumerate(msg["csv_fields"]):
|
||||
row[f"field_{i}"] = field.strip()
|
||||
writer.writerow(row)
|
||||
|
||||
print(f"✓ Exported parsed fields to {csv_parsed_filename}")
|
||||
|
||||
# Show sample text from a few messages
|
||||
print(f"\n{'=' * 80}")
|
||||
print("SAMPLE MESSAGE CONTENT:")
|
||||
print(f"{'=' * 80}")
|
||||
for i, msg in enumerate(messages_208[:3]):
|
||||
print(f"\nMessage #{msg['message_number']}:")
|
||||
if msg.get("text_ascii"):
|
||||
# Show with visible whitespace
|
||||
text = msg["text_ascii"].replace('\r', '\\r').replace('\n', '\\n').replace('\t', '\\t')
|
||||
print(f" {text}")
|
||||
else:
|
||||
print(f" (binary data)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user