Files
rtk-rover/rtk_rover_recorder.py
T

825 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys, time, struct, argparse, threading, os, sqlite3, uuid, select, termios, tty, json
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("--cmd-file", default="/home/naue/rtk/rtk_rover_cmd.json",
help="Command file for web UI start/stop recording")
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})")
# Web/API command file: {"cmd":"start"} or {"cmd":"stop"}
try:
if args.cmd_file and os.path.exists(args.cmd_file):
with open(args.cmd_file, "r", encoding="utf-8") as f:
cmd = json.load(f)
try:
os.remove(args.cmd_file)
except Exception:
pass
action = str(cmd.get("cmd", "")).lower()
note = str(cmd.get("note", "web"))
if action == "start" and not recorder.is_recording():
recorder.start(note=note)
print(f"⏺️ Web Recording START (session={recorder.session_id})", flush=True)
elif action == "stop" and recorder.is_recording():
recorder.stop()
print("⏹️ Web Recording STOP", flush=True)
except Exception as e:
print(f"WEB CMD ERROR: {e}", flush=True)
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()