801 lines
29 KiB
Python
801 lines
29 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
import sys, time, struct, argparse, threading, os, sqlite3, uuid, select, termios, tty
|
||
from typing import Optional, Tuple, Dict
|
||
import serial
|
||
from serial.serialutil import SerialException
|
||
from site_logger import SiteLogger
|
||
|
||
# -------------------- Defaults --------------------
|
||
DEF_ZED_PORT = "/dev/serial/by-id/usb-u-blox_AG_-_www.u-blox.com_u-blox_GNSS_receiver-if00"
|
||
DEF_ZED_BAUD = 115200
|
||
DEF_LORA_PORT = "/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0"
|
||
DEF_LORA_BAUD = 9600
|
||
|
||
PRINT_EVERY_S = 1.0
|
||
RTCM_GAP_WARN = 3.0
|
||
READ_TIMEOUT_S = 0.1
|
||
REOPEN_WAIT_S = 1.0
|
||
|
||
DEF_DB_PATH = os.path.expanduser("~/rtk/rtk_rover.db")
|
||
DEF_LOG_DIR = os.path.expanduser("~/rtk/logs")
|
||
|
||
# empirischer Offset: hMSL_raw + offset = hMSL_corr
|
||
DEFAULT_MSL_OFFSET = -31.5
|
||
|
||
# -------------------- UBX helpers --------------------
|
||
def ubx_checksum(payload: bytes) -> bytes:
|
||
a = b = 0
|
||
for x in payload:
|
||
a = (a + x) & 0xFF
|
||
b = (b + a) & 0xFF
|
||
return bytes([a, b])
|
||
|
||
def send_ubx(ser: serial.Serial, cls: int, mid: int, payload: bytes = b"") -> None:
|
||
head = b"\xB5\x62" + bytes([cls, mid]) + struct.pack("<H", len(payload))
|
||
ser.write(head + payload + ubx_checksum(bytes([cls, mid]) + struct.pack("<H", len(payload)) + payload))
|
||
|
||
def read_ubx_frame(ser: serial.Serial, timeout: float = 0.5):
|
||
end = time.time() + timeout
|
||
while time.time() < end:
|
||
b = ser.read(1)
|
||
if not b:
|
||
continue
|
||
if b == b"\xB5" and ser.read(1) == b"\x62":
|
||
break
|
||
else:
|
||
return None
|
||
hdr = ser.read(4)
|
||
if len(hdr) < 4:
|
||
return None
|
||
cls, mid, length = hdr[0], hdr[1], struct.unpack("<H", hdr[2:4])[0]
|
||
payload = ser.read(length)
|
||
cks = ser.read(2)
|
||
if len(payload) != length or len(cks) != 2:
|
||
return None
|
||
if ubx_checksum(bytes([cls, mid]) + struct.pack("<H", length) + payload) != cks:
|
||
return None
|
||
return (cls, mid, payload)
|
||
|
||
def wait_ack(ser: serial.Serial, exp_cls: int, exp_id: int, timeout: float = 1.0) -> bool:
|
||
end = time.time() + timeout
|
||
while time.time() < end:
|
||
f = read_ubx_frame(ser, end - time.time())
|
||
if not f:
|
||
continue
|
||
c, m, p = f
|
||
if c == 0x05 and m in (0x00, 0x01) and len(p) == 2 and (p[0], p[1]) == (exp_cls, exp_id):
|
||
return m == 0x01
|
||
return False
|
||
|
||
# -------------------- CFG / MON --------------------
|
||
def mon_ver(ser) -> Tuple[str, str]:
|
||
send_ubx(ser, 0x0A, 0x04)
|
||
v1 = v2 = "?"
|
||
end = time.time() + 0.6
|
||
while time.time() < end:
|
||
f = read_ubx_frame(ser, 0.1)
|
||
if not f or f[0] != 0x0A or f[1] != 0x04:
|
||
continue
|
||
p = f[2]
|
||
for i in range(0, len(p), 30):
|
||
s = p[i:i+30].split(b"\x00", 1)[0].decode("ascii", errors="ignore")
|
||
if s.startswith(("ROM", "EXT", "PROT")): v1 = s
|
||
if s.startswith("FWVER"): v2 = s
|
||
return v1, v2
|
||
|
||
def get_cfg_tmode3(ser):
|
||
send_ubx(ser, 0x06, 0x71)
|
||
f = read_ubx_frame(ser, 0.8)
|
||
if not f or f[0] != 0x06 or f[1] != 0x71 or len(f[2]) < 40:
|
||
return None
|
||
p = f[2]
|
||
mode = p[2]
|
||
return (mode, p)
|
||
|
||
def set_tmode3_disabled(ser) -> bool:
|
||
payload = bytearray(40)
|
||
payload[0] = 0
|
||
payload[2] = 0
|
||
send_ubx(ser, 0x06, 0x71, payload)
|
||
return wait_ack(ser, 0x06, 0x71, 1.0)
|
||
|
||
def set_nav_rate_1hz(ser):
|
||
payload = struct.pack("<HHH", 1000, 1, 0)
|
||
send_ubx(ser, 0x06, 0x08, payload)
|
||
wait_ack(ser, 0x06, 0x08, 0.5)
|
||
|
||
def quiet_nmea_usb(ser, usb_rate=0):
|
||
for mid in [0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09]:
|
||
payload = bytes([0xF0, mid, 0,0,0, usb_rate, 0])
|
||
send_ubx(ser, 0x06, 0x01, payload)
|
||
wait_ack(ser, 0x06, 0x01, 0.3)
|
||
|
||
def enable_nav_msgs_on_usb(ser):
|
||
for (cls, mid) in [(0x01,0x07), (0x01,0x14)]: # NAV-PVT, HPPOSLLH
|
||
payload = bytes([cls, mid, 0,0,0, 1, 0]) # USB index=3 -> rate=1
|
||
send_ubx(ser, 0x06, 0x01, payload)
|
||
wait_ack(ser, 0x06, 0x01, 0.5)
|
||
|
||
# -------------------- NAV Parser --------------------
|
||
def parse_nav_pvt(p):
|
||
if len(p) < 92: return None
|
||
iTOW = struct.unpack_from("<I", p, 0)[0]
|
||
year, month, day = struct.unpack_from("<HBB", p, 4)
|
||
hour, minute, second = struct.unpack_from("<BBB", p, 6)
|
||
fixType = p[20]
|
||
flags = p[21]
|
||
numSV = p[23]
|
||
lon = struct.unpack_from("<i", p, 24)[0] * 1e-7
|
||
lat = struct.unpack_from("<i", p, 28)[0] * 1e-7
|
||
height = struct.unpack_from("<i", p, 32)[0] / 1000.0
|
||
hMSL = struct.unpack_from("<i", p, 36)[0] / 1000.0
|
||
hAcc = struct.unpack_from("<I", p, 40)[0] / 1000.0
|
||
vAcc = struct.unpack_from("<I", p, 44)[0] / 1000.0
|
||
carrSoln = (p[21] >> 6) & 0x03 # 0 none,1 float,2 fix
|
||
return dict(iTOW=iTOW, time=(year,month,day,hour,minute,second),
|
||
fixType=fixType, gnssFixOK=(flags&1)!=0, diffSoln=(flags&2)!=0,
|
||
carrSoln=carrSoln, numSV=numSV, lat=lat, lon=lon,
|
||
height=height, hMSL=hMSL, hAcc=hAcc, vAcc=vAcc)
|
||
|
||
def parse_nav_hpposllh(p):
|
||
if len(p) < 36: return None
|
||
iTOW = struct.unpack_from("<I", p, 0)[0]
|
||
lon = struct.unpack_from("<i", p, 4)[0] * 1e-7
|
||
lat = struct.unpack_from("<i", p, 8)[0] * 1e-7
|
||
height = struct.unpack_from("<i", p, 12)[0] / 1000.0
|
||
hMSL = struct.unpack_from("<i", p, 16)[0] / 1000.0
|
||
lonHP = struct.unpack_from("<b", p, 20)[0] * 1e-9
|
||
latHP = struct.unpack_from("<b", p, 21)[0] * 1e-9
|
||
hHP = struct.unpack_from("<b", p, 22)[0] / 1000.0
|
||
hMSLHP = struct.unpack_from("<b", p, 23)[0] / 1000.0
|
||
hAcc = struct.unpack_from("<I", p, 24)[0] / 10000.0
|
||
vAcc = struct.unpack_from("<I", p, 28)[0] / 10000.0
|
||
return dict(iTOW=iTOW, lat=lat+latHP, lon=lon+lonHP,
|
||
height=height+hHP, hMSL=hMSL+hMSLHP, hAcc=hAcc, vAcc=vAcc)
|
||
|
||
# -------------------- RTCM Prüfer / Router --------------------
|
||
CRC24Q_POLY = 0x1864CFB
|
||
def crc24q(data: bytes) -> int:
|
||
crc = 0
|
||
for b in data:
|
||
crc ^= (b << 16)
|
||
for _ in range(8):
|
||
crc <<= 1
|
||
if crc & 0x1000000:
|
||
crc ^= CRC24Q_POLY
|
||
return crc & 0xFFFFFF
|
||
|
||
def parse_rtcm3_header_and_type(buf: bytes) -> Optional[Tuple[int,int]]:
|
||
if len(buf) < 6 or buf[0] != 0xD3:
|
||
return None
|
||
length = ((buf[1] & 0x03) << 8) | buf[2]
|
||
need = 3 + length + 3
|
||
if len(buf) < need:
|
||
return None
|
||
body = buf[:3+length]
|
||
crc_rx = (buf[3+length] << 16) | (buf[3+length+1] << 8) | buf[3+length+2]
|
||
if crc24q(body) != crc_rx:
|
||
return (-1, need)
|
||
msgnum = ((buf[3] & 0xFC) >> 2) << 8 | buf[4]
|
||
return (msgnum, need)
|
||
|
||
class RTCMRouter(threading.Thread):
|
||
def __init__(self, lora_port, lora_baud, zed_ser: serial.Serial, stats: Dict):
|
||
super().__init__(daemon=True)
|
||
self.lora_port = lora_port
|
||
self.lora_baud = lora_baud
|
||
self.zed_ser = zed_ser
|
||
self.stats = stats
|
||
self.stop_flag = threading.Event()
|
||
self.ser = None
|
||
self.buf = bytearray()
|
||
|
||
def open_lora(self):
|
||
if self.ser and self.ser.is_open: return
|
||
self.ser = serial.Serial(self.lora_port, self.lora_baud, timeout=READ_TIMEOUT_S)
|
||
|
||
def close_lora(self):
|
||
if self.ser:
|
||
try: self.ser.close()
|
||
except: pass
|
||
self.ser = None
|
||
|
||
def run(self):
|
||
while not self.stop_flag.is_set():
|
||
try:
|
||
if not self.ser or not self.ser.is_open:
|
||
self.open_lora()
|
||
data = self.ser.read(512)
|
||
if data:
|
||
self.stats["lora_bytes"] = self.stats.get("lora_bytes", 0) + len(data)
|
||
self.buf.extend(data)
|
||
i = 0
|
||
while i < len(self.buf):
|
||
if self.buf[i] != 0xD3:
|
||
i += 1
|
||
continue
|
||
parsed = parse_rtcm3_header_and_type(self.buf[i:])
|
||
if not parsed:
|
||
break
|
||
msgnum, framelen = parsed
|
||
frame = self.buf[i:i+framelen]
|
||
if msgnum == -1:
|
||
self.stats["rtcm_crc_err"] = self.stats.get("rtcm_crc_err", 0) + 1
|
||
i += framelen
|
||
continue
|
||
self.stats["rtcm_last_ok"] = time.time()
|
||
self.stats["rtcm_count"] = self.stats.get("rtcm_count", 0) + 1
|
||
self.stats["rtcm_preambles"] = self.stats.get("rtcm_preambles", 0) + 1
|
||
ty = self.stats.setdefault("rtcm_types", {})
|
||
ty[msgnum] = ty.get(msgnum, 0) + 1
|
||
try:
|
||
self.zed_ser.write(frame)
|
||
except SerialException:
|
||
pass
|
||
i += framelen
|
||
if i > 0:
|
||
del self.buf[:i]
|
||
else:
|
||
time.sleep(0.01)
|
||
except SerialException:
|
||
self.close_lora()
|
||
time.sleep(REOPEN_WAIT_S)
|
||
except Exception:
|
||
time.sleep(0.05)
|
||
|
||
def stop(self):
|
||
self.stop_flag.set()
|
||
self.close_lora()
|
||
|
||
# -------------------- Plausibility Filter --------------------
|
||
def is_plausible_fix(lat: Optional[float], lon: Optional[float], h_m: Optional[float],
|
||
hacc_m: Optional[float], vacc_m: Optional[float]) -> bool:
|
||
if lat is None or lon is None:
|
||
return False
|
||
if not (-90.0 <= lat <= 90.0):
|
||
return False
|
||
if not (-180.0 <= lon <= 180.0):
|
||
return False
|
||
if h_m is not None and not (-500.0 <= h_m <= 10000.0):
|
||
return False
|
||
if hacc_m is not None and hacc_m > 1000.0:
|
||
return False
|
||
if vacc_m is not None and vacc_m > 2000.0:
|
||
return False
|
||
return True
|
||
|
||
# -------------------- Recording / DB --------------------
|
||
SCHEMA_SQL = """
|
||
PRAGMA journal_mode=WAL;
|
||
PRAGMA synchronous=NORMAL;
|
||
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
session_id TEXT PRIMARY KEY,
|
||
start_ts REAL NOT NULL,
|
||
end_ts REAL,
|
||
device_id TEXT,
|
||
note TEXT
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS gnss_pvt (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL,
|
||
ts_utc TEXT,
|
||
unix_ts REAL NOT NULL,
|
||
iTOW_ms INTEGER,
|
||
lat REAL,
|
||
lon REAL,
|
||
height_m REAL,
|
||
hmsl_m REAL,
|
||
hmsl_corr_m REAL,
|
||
hacc_m REAL,
|
||
vacc_m REAL,
|
||
fix_type INTEGER,
|
||
carr_soln INTEGER,
|
||
diff_soln INTEGER,
|
||
gnss_ok INTEGER,
|
||
num_sats INTEGER,
|
||
source TEXT,
|
||
synced INTEGER DEFAULT 0,
|
||
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_gnss_session_time ON gnss_pvt(session_id, unix_ts);
|
||
CREATE INDEX IF NOT EXISTS idx_gnss_unsynced ON gnss_pvt(synced) WHERE synced=0;
|
||
|
||
-- Future processing / construction workflow tables
|
||
|
||
CREATE TABLE IF NOT EXISTS gnss_pvt_clean (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
raw_id INTEGER,
|
||
session_id TEXT NOT NULL,
|
||
ts_utc TEXT,
|
||
unix_ts REAL NOT NULL,
|
||
iTOW_ms INTEGER,
|
||
lat REAL,
|
||
lon REAL,
|
||
height_m REAL,
|
||
hmsl_m REAL,
|
||
hmsl_corr_m REAL,
|
||
hacc_m REAL,
|
||
vacc_m REAL,
|
||
fix_type INTEGER,
|
||
carr_soln INTEGER,
|
||
diff_soln INTEGER,
|
||
gnss_ok INTEGER,
|
||
num_sats INTEGER,
|
||
source TEXT,
|
||
quality_score REAL,
|
||
processing_run_id INTEGER,
|
||
FOREIGN KEY(session_id) REFERENCES sessions(session_id),
|
||
FOREIGN KEY(raw_id) REFERENCES gnss_pvt(id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_clean_session_time ON gnss_pvt_clean(session_id, unix_ts);
|
||
|
||
CREATE TABLE IF NOT EXISTS processing_runs (
|
||
run_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL,
|
||
started_ts REAL NOT NULL,
|
||
finished_ts REAL,
|
||
algorithm TEXT,
|
||
parameters_json TEXT,
|
||
note TEXT,
|
||
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS processing_flags (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL,
|
||
raw_id INTEGER,
|
||
flag_type TEXT NOT NULL,
|
||
severity TEXT,
|
||
reason TEXT,
|
||
created_ts REAL NOT NULL,
|
||
processing_run_id INTEGER,
|
||
FOREIGN KEY(session_id) REFERENCES sessions(session_id),
|
||
FOREIGN KEY(raw_id) REFERENCES gnss_pvt(id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_flags_session_raw ON processing_flags(session_id, raw_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS roll_segments (
|
||
roll_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL,
|
||
start_ts REAL NOT NULL,
|
||
end_ts REAL,
|
||
start_raw_id INTEGER,
|
||
end_raw_id INTEGER,
|
||
source TEXT NOT NULL DEFAULT 'auto',
|
||
confidence REAL,
|
||
label TEXT,
|
||
note TEXT,
|
||
created_ts REAL NOT NULL,
|
||
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_roll_segments_session_time ON roll_segments(session_id, start_ts, end_ts);
|
||
|
||
CREATE TABLE IF NOT EXISTS manual_events (
|
||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT,
|
||
unix_ts REAL NOT NULL,
|
||
event_type TEXT NOT NULL,
|
||
label TEXT,
|
||
note TEXT,
|
||
lat REAL,
|
||
lon REAL,
|
||
hmsl_corr_m REAL,
|
||
created_ts REAL NOT NULL,
|
||
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_manual_events_session_time ON manual_events(session_id, unix_ts);
|
||
|
||
"""
|
||
|
||
def iso_utc_from_pvt_time(tup) -> Optional[str]:
|
||
try:
|
||
y,mo,d,hh,mm,ss = tup
|
||
return f"{y:04d}-{mo:02d}-{d:02d}T{hh:02d}:{mm:02d}:{ss:02d}Z"
|
||
except Exception:
|
||
return None
|
||
|
||
class Recorder:
|
||
def __init__(self, db_path: str, device_id: str = None, log_dir: Optional[str] = None,
|
||
csv: bool = False, msl_offset: float = 0.0):
|
||
self.db_path = db_path
|
||
self.device_id = device_id
|
||
self.log_dir = log_dir
|
||
self.csv_enabled = csv
|
||
self.msl_offset = msl_offset
|
||
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||
self.conn.execute("PRAGMA foreign_keys=ON;")
|
||
self.conn.executescript(SCHEMA_SQL)
|
||
self.conn.commit()
|
||
|
||
# --- Schema-Version pruefen / setzen ---
|
||
cur = self.conn.execute("PRAGMA user_version;")
|
||
version = cur.fetchone()[0]
|
||
|
||
if version == 0:
|
||
print("DB Schema init -> Version 1")
|
||
self.conn.execute("PRAGMA user_version = 1;")
|
||
self.conn.commit()
|
||
|
||
self.session_id: Optional[str] = None
|
||
self.csv_fh = None
|
||
|
||
if self.log_dir:
|
||
os.makedirs(self.log_dir, exist_ok=True)
|
||
|
||
self.lock = threading.Lock()
|
||
|
||
def is_recording(self) -> bool:
|
||
return self.session_id is not None
|
||
|
||
def start(self, note: str = None):
|
||
with self.lock:
|
||
if self.session_id:
|
||
return
|
||
sid = str(uuid.uuid4())
|
||
now = time.time()
|
||
self.conn.execute(
|
||
"INSERT INTO sessions(session_id,start_ts,device_id,note) VALUES(?,?,?,?)",
|
||
(sid, now, self.device_id, note)
|
||
)
|
||
self.conn.commit()
|
||
self.session_id = sid
|
||
|
||
if self.csv_enabled and self.log_dir:
|
||
fn = os.path.join(self.log_dir, f"session_{sid}.csv")
|
||
self.csv_fh = open(fn, "w", encoding="utf-8")
|
||
self.csv_fh.write("unix_ts,ts_utc,iTOW_ms,lat,lon,height_m,hmsl_m,hmsl_corr_m,hacc_m,vacc_m,fix_type,carr_soln,diff_soln,gnss_ok,num_sats,source\n")
|
||
self.csv_fh.flush()
|
||
|
||
def stop(self):
|
||
with self.lock:
|
||
if not self.session_id:
|
||
return
|
||
now = time.time()
|
||
self.conn.execute("UPDATE sessions SET end_ts=? WHERE session_id=?", (now, self.session_id))
|
||
self.conn.commit()
|
||
self.session_id = None
|
||
if self.csv_fh:
|
||
try: self.csv_fh.close()
|
||
except: pass
|
||
self.csv_fh = None
|
||
|
||
def add_fix(self, pvt: Optional[dict], hpp: Optional[dict]):
|
||
with self.lock:
|
||
if not self.session_id:
|
||
return
|
||
|
||
unix_ts = time.time()
|
||
ts_utc = iso_utc_from_pvt_time(pvt["time"]) if pvt else None
|
||
iTOW = int(pvt["iTOW"]) if pvt else (int(hpp["iTOW"]) if hpp else None)
|
||
|
||
use_hp = False
|
||
if hpp:
|
||
use_hp = is_plausible_fix(hpp.get("lat"), hpp.get("lon"), hpp.get("height"), hpp.get("hAcc"), hpp.get("vAcc"))
|
||
|
||
if use_hp:
|
||
lat, lon = hpp["lat"], hpp["lon"]
|
||
height, hmsl = hpp["height"], hpp["hMSL"]
|
||
hacc, vacc = hpp["hAcc"], hpp["vAcc"]
|
||
source = "HPPOSLLH"
|
||
elif pvt:
|
||
lat, lon = pvt["lat"], pvt["lon"]
|
||
height, hmsl = pvt["height"], pvt["hMSL"]
|
||
hacc, vacc = pvt["hAcc"], pvt["vAcc"]
|
||
source = "PVT"
|
||
else:
|
||
return
|
||
|
||
hmsl_corr = (hmsl + self.msl_offset) if (hmsl is not None) else None
|
||
|
||
fix_type = int(pvt["fixType"]) if pvt else None
|
||
carr_soln = int(pvt["carrSoln"]) if pvt else None
|
||
diff_soln = int(1 if pvt and pvt.get("diffSoln") else 0) if pvt else None
|
||
gnss_ok = int(1 if pvt and pvt.get("gnssFixOK") else 0) if pvt else None
|
||
num_sats = int(pvt["numSV"]) if pvt else None
|
||
|
||
self.conn.execute(
|
||
"""INSERT INTO gnss_pvt(session_id,ts_utc,unix_ts,iTOW_ms,lat,lon,height_m,hmsl_m,hmsl_corr_m,hacc_m,vacc_m,
|
||
fix_type,carr_soln,diff_soln,gnss_ok,num_sats,source)
|
||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(self.session_id, ts_utc, unix_ts, iTOW, lat, lon, height, hmsl, hmsl_corr, hacc, vacc,
|
||
fix_type, carr_soln, diff_soln, gnss_ok, num_sats, source)
|
||
)
|
||
self.conn.commit()
|
||
|
||
if self.csv_fh:
|
||
self.csv_fh.write(f"{unix_ts},{ts_utc or ''},{iTOW or ''},{lat},{lon},{height},{hmsl},{hmsl_corr},{hacc},{vacc},{fix_type},{carr_soln},{diff_soln},{gnss_ok},{num_sats},{source}\n")
|
||
self.csv_fh.flush()
|
||
|
||
def close(self):
|
||
# Do not call self.stop() while holding self.lock:
|
||
# stop() also takes the same lock -> deadlock with threading.Lock.
|
||
try:
|
||
if self.session_id:
|
||
self.stop()
|
||
finally:
|
||
try:
|
||
self.conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# -------------------- UI helpers --------------------
|
||
def fix_type_str(pvt) -> str:
|
||
if not pvt: return "—"
|
||
base = {0:"NO FIX",1:"DEAD RECK",2:"2D",3:"3D",4:"GNSS+DR",5:"TIME"}.get(pvt["fixType"], "?")
|
||
if pvt["carrSoln"] == 2: return base + " / RTK FIX"
|
||
if pvt["carrSoln"] == 1: return base + " / RTK FLOAT"
|
||
if pvt["diffSoln"]: return base + " / DGNSS"
|
||
return base
|
||
|
||
class StdinKeyReader:
|
||
def __init__(self):
|
||
self.fd = sys.stdin.fileno()
|
||
self.old = termios.tcgetattr(self.fd)
|
||
tty.setcbreak(self.fd)
|
||
|
||
def read_key(self) -> Optional[str]:
|
||
r, _, _ = select.select([sys.stdin], [], [], 0)
|
||
if r:
|
||
return sys.stdin.read(1)
|
||
return None
|
||
|
||
def close(self):
|
||
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old)
|
||
|
||
# -------------------- Main --------------------
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="RTK Rover Recorder (ZED-F9P + LoRa + SQLite)")
|
||
ap.add_argument("--zed", default=DEF_ZED_PORT)
|
||
ap.add_argument("--zed-baud", type=int, default=DEF_ZED_BAUD)
|
||
ap.add_argument("--lora", default=DEF_LORA_PORT)
|
||
ap.add_argument("--lora-baud", type=int, default=DEF_LORA_BAUD)
|
||
ap.add_argument("--print-every", type=float, default=PRINT_EVERY_S)
|
||
|
||
ap.add_argument("--db", default=DEF_DB_PATH)
|
||
ap.add_argument("--device-id", default=None)
|
||
ap.add_argument("--log-dir", default=DEF_LOG_DIR)
|
||
ap.add_argument("--csv", action="store_true")
|
||
ap.add_argument("--msl-offset", type=float, default=DEFAULT_MSL_OFFSET,
|
||
help="Apply correction: hMSL_corr = hMSL_raw + msl_offset")
|
||
ap.add_argument("--auto-record", action="store_true",
|
||
help="Start SQLite recording automatically on service start")
|
||
ap.add_argument("--record-note", default="auto",
|
||
help="Note stored in sessions table when --auto-record is used")
|
||
|
||
ap.add_argument("--site-log", action="store_true",
|
||
help="Enable construction-site FIX-only CSV/GeoJSON/status logging")
|
||
ap.add_argument("--site-log-dir", default="/var/log/rtk/projects",
|
||
help="Base directory for construction-site logs")
|
||
ap.add_argument("--site-project", default=None,
|
||
help="Project/session folder name; default timestamp or RTK_PROJECT env")
|
||
ap.add_argument("--site-min-fix-seconds", type=float, default=5.0,
|
||
help="Require continuous RTK FIX for N seconds before logging")
|
||
ap.add_argument("--site-max-hacc", type=float, default=0.05,
|
||
help="Only log fixes with hAcc <= this value in meters")
|
||
|
||
args = ap.parse_args()
|
||
|
||
print(f"🔌 Öffne ZED {args.zed} @ {args.zed_baud} …")
|
||
try:
|
||
zed = serial.Serial(args.zed, args.zed_baud, timeout=READ_TIMEOUT_S)
|
||
except Exception as e:
|
||
print(f"❌ Konnte ZED-Port nicht öffnen: {e}"); sys.exit(1)
|
||
print("✅ ZED-Port offen.")
|
||
|
||
print("🔎 Diagnose…")
|
||
v1, v2 = mon_ver(zed)
|
||
print(f" MON-VER: ('{v1}', '{v2}')")
|
||
st0 = get_cfg_tmode3(zed)
|
||
print(f" TMODE3 vorab: {st0}")
|
||
|
||
print("\n↩️ TMODE3=DISABLED (Rover)")
|
||
print(f" ACK: {'OK' if set_tmode3_disabled(zed) else 'kein ACK'}")
|
||
|
||
print("⚙️ NAV-Ausgaben (USB) setzen: NAV-PVT + HPPOSLLH (USB=1)")
|
||
enable_nav_msgs_on_usb(zed)
|
||
set_nav_rate_1hz(zed)
|
||
quiet_nmea_usb(zed, usb_rate=0)
|
||
|
||
stats = {"rtcm_last_ok": 0.0, "rtcm_count": 0, "rtcm_types": {},
|
||
"lora_bytes": 0, "rtcm_crc_err": 0, "rtcm_preambles": 0}
|
||
|
||
rtcm_router = RTCMRouter(args.lora, args.lora_baud, zed, stats)
|
||
try:
|
||
rtcm_router.start()
|
||
except Exception as e:
|
||
print(f"⚠️ Konnte LoRa-Thread nicht starten: {e}")
|
||
|
||
recorder = Recorder(args.db, device_id=args.device_id, log_dir=args.log_dir, csv=args.csv,
|
||
msl_offset=args.msl_offset)
|
||
|
||
if args.auto_record:
|
||
recorder.start(note=args.record_note)
|
||
print(f"⏺️ SQLite Auto-Recording START (session={recorder.session_id})")
|
||
|
||
site_logger = None
|
||
if args.site_log:
|
||
site_logger = SiteLogger(
|
||
base_dir=args.site_log_dir,
|
||
project_name=args.site_project,
|
||
min_fix_seconds=args.site_min_fix_seconds,
|
||
max_hacc_m=args.site_max_hacc,
|
||
msl_offset=args.msl_offset,
|
||
)
|
||
print(f"🏗️ Baustellen-Logging aktiv → {site_logger.project_dir}")
|
||
|
||
print(f"\n🧭 msl_offset={args.msl_offset:+.3f} m (hMSL_corr = hMSL_raw + offset)")
|
||
print("\n🎛️ Steuerung: 'r' = Recording Start/Stop | 'q' = Quit (sauber)")
|
||
print(f"🗃️ DB: {args.db}")
|
||
if args.csv:
|
||
print(f"🧾 CSV: aktiv → {args.log_dir}")
|
||
|
||
keyr = None
|
||
try:
|
||
keyr = StdinKeyReader()
|
||
except Exception:
|
||
print("⚠️ Kein TTY – Keyboard-Steuerung deaktiviert (normal im systemd Service).")
|
||
|
||
last_print = 0.0
|
||
last_nav_seen = 0.0
|
||
first_poll_deadline = time.time() + 3.0
|
||
|
||
last_pvt = None
|
||
last_hpp = None
|
||
last_recorded_itow = None
|
||
|
||
try:
|
||
while True:
|
||
if keyr:
|
||
k = keyr.read_key()
|
||
if k:
|
||
if k.lower() == "q":
|
||
break
|
||
if k.lower() == "r":
|
||
if recorder.is_recording():
|
||
recorder.stop()
|
||
print("⏹️ Recording STOP")
|
||
else:
|
||
recorder.start(note="manual")
|
||
print(f"⏺️ Recording START (session={recorder.session_id})")
|
||
|
||
f = read_ubx_frame(zed, timeout=0.05)
|
||
pvt = None
|
||
hpp = None
|
||
if f:
|
||
frames = [f]
|
||
t_end = time.time() + 0.04
|
||
while time.time() < t_end:
|
||
nf = read_ubx_frame(zed, timeout=0.01)
|
||
if not nf:
|
||
break
|
||
frames.append(nf)
|
||
for c, m, p in frames:
|
||
if (c, m) == (0x01, 0x07):
|
||
pvt = parse_nav_pvt(p); last_nav_seen = time.time()
|
||
elif (c, m) == (0x01, 0x14):
|
||
hpp = parse_nav_hpposllh(p); last_nav_seen = time.time()
|
||
|
||
if pvt: last_pvt = pvt
|
||
if hpp: last_hpp = hpp
|
||
|
||
if time.time() > first_poll_deadline and last_nav_seen == 0.0:
|
||
send_ubx(zed, 0x01, 0x07)
|
||
first_poll_deadline = float("inf")
|
||
|
||
if recorder.is_recording() and (last_pvt or last_hpp):
|
||
current_itow = None
|
||
if last_pvt:
|
||
current_itow = last_pvt.get("iTOW")
|
||
elif last_hpp:
|
||
current_itow = last_hpp.get("iTOW")
|
||
|
||
if current_itow is not None and current_itow != last_recorded_itow:
|
||
try:
|
||
recorder.add_fix(last_pvt, last_hpp)
|
||
last_recorded_itow = current_itow
|
||
except Exception as e:
|
||
print(f"DB add_fix ERROR: {e}", flush=True)
|
||
|
||
if site_logger is not None and pvt:
|
||
rtcm_age_s = None
|
||
if stats.get("rtcm_count", 0) > 0:
|
||
rtcm_age_s = time.time() - stats.get("rtcm_last_ok", 0.0)
|
||
site_logger.update(last_pvt, last_hpp, rtcm_age_s=rtcm_age_s)
|
||
|
||
now = time.time()
|
||
if now - last_print >= args.print_every:
|
||
last_print = now
|
||
|
||
if stats["rtcm_count"] == 0:
|
||
rtcm_info = f"RTCM: (noch nichts empfangen) | LoRa-Bytes={stats['lora_bytes']}"
|
||
else:
|
||
dt = now - stats["rtcm_last_ok"]
|
||
warn = " ⚠️" if dt > RTCM_GAP_WARN else ""
|
||
rtcm_info = f"RTCM: ok (letzte gültige vor {dt:.1f}s){warn} | Frames={stats['rtcm_count']} | CRC-Err={stats['rtcm_crc_err']} | LoRa-Bytes={stats['lora_bytes']}"
|
||
|
||
p = last_pvt
|
||
hp = last_hpp
|
||
fixs = fix_type_str(p) if p else "—"
|
||
|
||
use_hp = False
|
||
if hp:
|
||
use_hp = is_plausible_fix(hp.get("lat"), hp.get("lon"), hp.get("height"), hp.get("hAcc"), hp.get("vAcc"))
|
||
|
||
if use_hp:
|
||
lat, lon = hp["lat"], hp["lon"]
|
||
h_msl_raw = hp["hMSL"]
|
||
h_ell = hp["height"]
|
||
hacc, vacc = hp["hAcc"], hp["vAcc"]
|
||
src = "HPPOSLLH"
|
||
elif p:
|
||
lat, lon = p["lat"], p["lon"]
|
||
h_msl_raw = p["hMSL"]
|
||
h_ell = p["height"]
|
||
hacc, vacc = p["hAcc"], p["vAcc"]
|
||
src = "PVT"
|
||
else:
|
||
lat = lon = None
|
||
h_msl_raw = h_ell = None
|
||
hacc = vacc = None
|
||
src = "—"
|
||
|
||
h_msl_corr = (h_msl_raw + args.msl_offset) if (h_msl_raw is not None) else None
|
||
N = (h_ell - h_msl_raw) if (h_ell is not None and h_msl_raw is not None) else None
|
||
|
||
rec = "REC" if recorder.is_recording() else "idle"
|
||
|
||
print("—"*72)
|
||
print(rtcm_info)
|
||
if last_nav_seen == 0.0:
|
||
print("UBX: (noch keine NAV-Daten gesehen) – prüfe CFG-MSG (USB) / Kabel")
|
||
else:
|
||
ago = now - last_nav_seen
|
||
if ago > 3.0:
|
||
print(f"UBX: letzte NAV vor {ago:.1f}s ⚠️")
|
||
|
||
if p:
|
||
print(f"[{rec}] Fix: {fixs} | Sats: {p['numSV']} | hAcc: {hacc:.3f} m | vAcc: {vacc:.3f} m | src={src}")
|
||
else:
|
||
print(f"[{rec}] Fix: — | src={src}")
|
||
|
||
if lat is not None:
|
||
print(f"Pos: lat={lat:.7f}, lon={lon:.7f}, hMSL_raw={h_msl_raw:.3f} m | hMSL_corr={h_msl_corr:.3f} m | hEll={h_ell:.3f} m | N={N:.3f} m")
|
||
else:
|
||
print("Pos: —")
|
||
|
||
time.sleep(0.02)
|
||
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
print("\n⏹️ Stoppe …")
|
||
try:
|
||
if keyr: keyr.close()
|
||
except: pass
|
||
try:
|
||
recorder.close()
|
||
except: pass
|
||
try:
|
||
if site_logger is not None:
|
||
site_logger.close()
|
||
except: pass
|
||
try:
|
||
rtcm_router.stop()
|
||
except: pass
|
||
try:
|
||
zed.close()
|
||
except: pass
|
||
print("✓ Beendet.")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|