diff --git a/receiver_cfg/LG290P_rtkbase_rtcm3.cfg b/receiver_cfg/LG290P_rtkbase_rtcm3.cfg new file mode 100644 index 00000000..85802960 --- /dev/null +++ b/receiver_cfg/LG290P_rtkbase_rtcm3.cfg @@ -0,0 +1,155 @@ +# LG290P base station configuration for RTKBase (receiver_format='rtcm3') +# +# Apply after a factory reset: +# ./lg290p_tool.py --port /dev/ttyACM0 --command reset --retry 5 +# ./lg290p_tool.py --port /dev/ttyACM0 --command send_config_file \ +# LG290P_rtkbase_rtcm3.cfg --store --retry 3 +# +# Checksums are computed by the tool - do not add them here. +# Directives: SLEEP | SAVE | RESET | +# +# --- KNOWN FIRMWARE QUIRK: two save/reset cycles are required -------------- +# Confirmed on firmware LG290P03AANR01A06S (2025/09/18 build), also reported +# by other users on the forum thread below - unconfirmed whether it is fixed +# in the newer 02A01S/02A02S firmware line (see SparkFun's LG290P firmware +# repo: https://github.com/sparkfun/SparkFun_RTK_Postcard/tree/main/Firmware +# - no changelog is published, so this has not been re-tested there). +# +# If PQTMCFGRTCM (MSM type) or PQTMCFGMSGRATE (message on/off) writes are +# sent in the *same* save+reset cycle as a PQTMCFGRCVRMODE (rover/base mode) +# change, they are silently discarded on the next reset - even though they +# ACK ,OK and read back correctly right up until that reset happens. This +# makes it look like the write worked when it didn't. +# +# Upstream thread: https://forums.quectel.com/t/msm7-cfgrtcm-not-being-respected-on-lg290p-after-a-restore-unless-its-done-again/39327 +# +# Workaround (verified against real hardware, base-mode MSM7 confirmed on +# the wire with a CRC-validated capture, 2026-08-14): commit the mode change +# with its own SAVE+RESET *first*, wait for it to fully come back up, and +# only then apply RTCM/message-rate settings in a second SAVE+RESET cycle. +# That is why this file has two SAVE/RESET blocks instead of one at the end +# - do not collapse them into a single cycle. +# +# If a future firmware fixes this, the two blocks can very likely be merged +# back into one SAVE+RESET at the end of the file. There's no need to rush +# that change even after a fix lands - the extra cycle just costs ~5-10s +# extra during setup and is otherwise harmless. +# ----------------------------------------------------------------------- + +# === phase 1: receiver mode ================================================ +# 2 = Base station. Must be fully saved+reset on its own before anything +# below is sent, per the quirk above. +PQTMCFGRCVRMODE,W,2 +SAVE +RESET +SLEEP 5 + +# === phase 2: RTCM3 + NMEA settings ========================================= + +# --- MSM type + ephemeris cadence (PQTMCFGRTCM) --------------------------- +# Fields per Quectel's LG29xP&LGx80P protocol spec (v1.3), section 2.3.39: +# MSM_Type=7 RTCM3 MSM7 (full resolution). Range 3-7, firmware +# default is 4 (MSM4). RTK accuracy is identical between +# MSM4/MSM7 - the only difference is observable +# resolution in RINEX, useful for post-processing/PPP of +# the base's own logs. Use 4 here instead if you'd rather +# match the firmware default and don't need that. +# MSM_Mode=0 Output mode when no satellite is searched. Spec says +# this is "always 0". +# MSM_ElevThd=5.0 Elevation mask in degrees for MSM reporting. +# Spec default is -90.0 (no mask); we use 5 deg to match +# Quectel's own base-station app-note example. +# Reserved=07,06 Spec defaults, meaning undocumented - leave as-is. +# EPH_Mode=2 Output ephemeris on update AND at a fixed interval +# (see EPH_Interval). This is independent of the +# per-message PQTMCFGMSGRATE settings for 1019/1020/ +# 1042/1046 below; both were observed enabled together +# without conflict. +# EPH_Interval=60 Regular ephemeris re-broadcast every 60s (seconds). +# IMPORTANT: was originally 3600 (hourly) to match Quectel's +# app-note example, but that means the FIRST ephemeris +# broadcast after a factory reset doesn't happen until the +# full interval elapses - confirmed by direct testing, no +# ephemeris appeared for 40+ minutes after a fresh reset +# with EPH_Interval=3600, while lowering it to 60 unblocked +# output immediately (live, no reset needed - EPH_Mode/ +# Interval changes apply without a reset, unlike CFGSVIN +# below). Only matters for consumers of ephemeris via RTCM +# (RINEX post-processing, rtkbase_raw2nmea's internal +# rtkrcv) - a live rover decodes ephemeris from its own +# antenna regardless and doesn't need this at all. +PQTMCFGRTCM,W,7,0,5.0,07,06,2,60 + +# --- survey-in / fixed position ----------------------------------------- +# CORRECTED 2026-08-15 - the assumption below (str2str synthesizes 1005/1006 +# from settings.conf via the -p flag, so it's safe to disable 1005 at the +# receiver) is WRONG and was proven wrong by direct testing: str2str's -p +# flag does NOT inject a usable 1005 into an already-#rtcm3 input stream when +# relaying/filtering (confirmed with both negative and positive coordinate +# values - it always emits ECEF 0,0,0). A rover fed that 1005 cannot compute +# a baseline and gets stuck at Autonomous fix forever, even with corrections +# otherwise flowing correctly. 1005 must come from the RECEIVER itself. +# +# Also: PQTMCFGSVIN changes need a RESET before they take effect on the +# wire - a plain SAVE is not enough (confirmed empirically; matches Quectel's +# own base-station app-note sequence, which resets immediately after this +# command). The RESET at the end of this file's phase 2 handles that. +# +# Two options, pick one: +# +# (a) Survey-in (default below, no per-site editing needed to get started). +# Caveat, confirmed on our own unit: the LG290P's internal survey-in +# validation is unreliable - PQTMCFGSVIN,R can sit at ECEF 0,0,0 for +# hours without ever validating, even while an independent PPP/single- +# point solution computed externally (e.g. RTKBase's status page, or +# rtkbase_raw2nmea) converges fine from the exact same observables. This +# matches community reports, not just our setup: +# https://forums.quectel.com/t/lg290p-correct-workflow-to-survey-in/42964 +# If 1005 is still broadcasting zeros after a reasonable wait, switch to +# option (b) instead of waiting indefinitely. +# +# (b) Fixed position (reliable, but needs a real coordinate for your site). +# tools/lg290p_set_fixed_position.py automates this: it averages a live +# position from rtkbase_raw2nmea (or accepts one you already have from a +# post-processing service like AUSPOS/OPUS via --llh/--ecef), converts to +# WGS84 ECEF if needed, and pushes + saves + resets the receiver for you. +# Any error in this position becomes an equal error in every rover's +# position - only use a value you trust. +PQTMCFGSVIN,W,1,3600,1.2,0,0,0 + +# --- RTCM3 ephemerides --------------------------------------------------- +# Required for convbin to produce a usable RINEX navigation file. Individual +# MSM component messages (RTCM3-1074/1077/etc) are NOT settable this way - +# the firmware rejects them with ERROR,1 regardless of mode. MSM output is +# controlled entirely by PQTMCFGRTCM above; only ephemeris and the station- +# coordinate message go through PQTMCFGMSGRATE by name. +PQTMCFGMSGRATE,W,RTCM3-1019,1 +PQTMCFGMSGRATE,W,RTCM3-1020,1 +PQTMCFGMSGRATE,W,RTCM3-1042,1 +PQTMCFGMSGRATE,W,RTCM3-1046,1 + +# --- station position ---------------------------------------------------- +# On (see the survey-in/fixed-position note above for why this must come +# from the receiver, not from RTKBase's str2str -p flag). This is the base +# station's antenna reference point - required for any rover to compute a +# baseline against this base. Firmware default cadence when enabled, not a +# configurable numeric rate - throttle further downstream if desired via +# RTKBase's own per-output -msg rate divisor, e.g. "1005(10)". +PQTMCFGMSGRATE,W,RTCM3-1005,1 + +# --- NMEA ---------------------------------------------------------------- +# GGA/RMC feed gpsd -> chrony for system time. Note that in base mode the +# LG290P populates these sentences but leaves the position fields empty, +# so RTKBase's map will not track from them - enable rtkbase_raw2nmea if +# time sync misbehaves. +PQTMCFGMSGRATE,W,GGA,1 +PQTMCFGMSGRATE,W,RMC,1 +PQTMCFGMSGRATE,W,GSA,1 +PQTMCFGMSGRATE,W,GSV,1 +PQTMCFGMSGRATE,W,GLL,0 +PQTMCFGMSGRATE,W,VTG,0 + +# --- persist ------------------------------------------------------------- +SAVE +RESET +SLEEP 5 diff --git a/tools/install.sh b/tools/install.sh index 8886ab68..255c6392 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -419,7 +419,7 @@ detect_gnss() { done if [[ ${#detected_gnss[*]} -ne 2 ]]; then vendor_and_product_ids=$(lsusb | grep -i "u-blox\|Septentrio" | grep -Eo "[0-9A-Za-z]+:[0-9A-Za-z]+") - if [[ -z "$vendor_and_product_ids" ]]; then + if [[ -z "$vendor_and_product_ids" ]]; then echo 'NO USB GNSS RECEIVER DETECTED' echo 'YOU CAN REDETECT IT FROM THE WEB UI' #return 1 @@ -459,6 +459,33 @@ detect_gnss() { [[ ${#detected_gnss[*]} -eq 3 ]] && break done fi + # detection of a Quectel LG290P + # Tried last, after the UART loop above: the CH343 USB-serial bridge on the + # Waveshare/SparkFun LG290P breakouts has no vendor-identifying ID_SERIAL string + # (it's a generic WCH bridge used on many unrelated devices), so it can't be + # matched by udev property like u-blox/Septentrio above, and needs its own probe. + # Running that probe only as a last resort (rather than before the UART loop) + # avoids adding a multi-port/multi-baud Quectel-specific scan - and unsolicited + # writes to whatever's on those ports - ahead of the existing F9P/Unicore + # detection on ttyUSB0-2, which would otherwise be delayed by it on every install + # where those receivers are connected over USB-serial. + # lg290p_tool.py --detect scans likely ports/bauds itself and only reports a match + # once it gets a real PQTMVERNO response, so detection is content-based rather + # than VID:PID-based. + # -lt 2 rather than -ne 2: both this probe and the UART loop above set 3 elements + # (port, vendor, baud) on a match, since they already know the baud - unlike the + # u-blox/Septentrio USB paths earlier, which only set 2 and leave baud to the + # default-fill just below. + if [[ ${#detected_gnss[*]} -lt 2 ]]; then + systemctl is-active --quiet str2str_tcp.service && systemctl stop str2str_tcp.service && echo 'Stopping str2str_tcp service' + lg290p_probe="$(python3 "${rtkbase_path}"/tools/lg290p_tool.py --detect 2>/dev/null)" + if [[ "${lg290p_probe}" =~ ^/dev/([^\ ]+)\ -\ quectel\ -\ ([0-9]+)\ - ]] + then + detected_gnss[0]="${BASH_REMATCH[1]}" + detected_gnss[1]='quectel' + detected_gnss[2]="${BASH_REMATCH[2]}" + fi + fi # Test if speed is in detected_gnss array. If not, add the default value. [[ ${#detected_gnss[*]} -eq 2 ]] && detected_gnss[2]='115200' # If /dev/ttyGNSS is a symlink of the detected serial port, switch to ttyGNSS @@ -486,6 +513,13 @@ detect_gnss() { detected_gnss[3]="$( { python3 "${rtkbase_path}"/tools/unicore_tool.py --port /dev/"${detected_gnss[0]}" --baudrate ${detected_gnss[2]} --command get_firmware --retry 5 2>/dev/null || echo '?'; } | tail -n 1 )" detected_gnss[4]="$( { python3 "${rtkbase_path}"/tools/unicore_tool.py --port /dev/"${detected_gnss[0]}" --baudrate ${detected_gnss[2]} --command get_model --retry 5 2>/dev/null || echo '?'; } | tail -n 1 )" + elif [[ "${detected_gnss[1]}" =~ 'quectel' ]] + then + detected_gnss[1]='quectel' + #get Quectel model and firmware release + detected_gnss[3]="$( { python3 "${rtkbase_path}"/tools/lg290p_tool.py --port /dev/"${detected_gnss[0]}" --baudrate ${detected_gnss[2]} --command get_firmware --retry 5 2>/dev/null || echo '?'; } | tail -n 1 )" + detected_gnss[4]="$( { python3 "${rtkbase_path}"/tools/lg290p_tool.py --port /dev/"${detected_gnss[0]}" --baudrate ${detected_gnss[2]} --command get_model --retry 5 2>/dev/null || echo '?'; } | tail -n 1 )" + fi # "send" result echo '/dev/'"${detected_gnss[0]}" ' - ' "${detected_gnss[1]}"' - ' "${detected_gnss[2]}"' - ' "${detected_gnss[3]}"' - ' "${detected_gnss[4]}" @@ -633,6 +667,40 @@ configure_gnss(){ return 1 fi + elif { model=$(python3 "${rtkbase_path}"/tools/lg290p_tool.py --port /dev/${com_port} --baudrate ${com_port_settings%%:*} --command get_model 2>/dev/null) ; [[ "${model}" == 'LG290P' ]] ;} + then + #get LG290P firmware release + firmware="$(python3 "${rtkbase_path}"/tools/lg290p_tool.py --port /dev/${com_port} --baudrate ${com_port_settings%%:*} --command get_firmware 2>/dev/null)" || firmware='?' + echo 'Quectel-' "${model}" 'Firmware: ' "${firmware}" + sudo -u "${RTKBASE_USER}" sed -i s/^receiver_firmware=.*/receiver_firmware=\'${firmware}\'/ "${rtkbase_path}"/settings.conf + #factory reset, then apply the RTKBase base-station config. It's split into two + #save+reset cycles (mode change, then RTCM/message-rate settings) to work around a + #firmware quirk - see receiver_cfg/LG290P_rtkbase_rtcm3.cfg and HANDOVER_lg290p_rtkbase.md + #for why. lg290p_tool.py blocks until the USB port re-enumerates after each reset, so + #no extra sleep is needed here (unlike the Unicore branch above). + echo 'Resetting the ' "${model}" ' settings....' + if ! python3 "${rtkbase_path}"/tools/lg290p_tool.py --port /dev/${com_port} --baudrate ${com_port_settings%%:*} --command reset --retry 5 + then + echo 'Failed to factory reset the Gnss receiver' + return 1 + fi + echo 'Sending settings....' + python3 "${rtkbase_path}"/tools/lg290p_tool.py --port /dev/${com_port} --baudrate ${com_port_settings%%:*} --command send_config_file "${rtkbase_path}"/receiver_cfg/LG290P_rtkbase_rtcm3.cfg --store --retry 3 + if [[ $? -eq 0 ]] + then + echo 'Quectel LG290P successfuly configured' + sudo -u "${RTKBASE_USER}" sed -i s/^com_port_settings=.*/com_port_settings=\'460800:8:n:1\'/ "${rtkbase_path}"/settings.conf && \ + sudo -u "${RTKBASE_USER}" sed -i s/^receiver=.*/receiver=\'Quectel_$model\'/ "${rtkbase_path}"/settings.conf && \ + sudo -u "${RTKBASE_USER}" sed -i s/^receiver_format=.*/receiver_format=\'rtcm3\'/ "${rtkbase_path}"/settings.conf + #matches the Unicore/Septentrio branches - RTCM3 logging needs more headroom + sudo -u "${RTKBASE_USER}" sed -i s/^min_free_space=.*/min_free_space=\'1500\'/ "${rtkbase_path}"/settings.conf + + return $? + else + echo 'Failed to configure the Gnss receiver' + return 1 + fi + else echo 'No Gnss receiver has been set. We can'\''t configure' return 1 @@ -708,6 +776,12 @@ start_services() { systemctl enable --now rtkbase_archive.timer grep -qE "^modem_at_port='/[[:alnum:]]+.*'" "${rtkbase_path}"/settings.conf && systemctl enable --now modem_check.timer grep -q "receiver='Septentrio_Mosaic-X5'" "${rtkbase_path}"/settings.conf && systemctl enable --now rtkbase_gnss_web_proxy.service + #gpsd can't get a time fix from a raw rtcm3/sbf/etc stream, only from NMEA - and in base + #mode the receiver's own NMEA position fields are typically empty anyway. raw2nmea.sh + #computes a real position+time from the observables and feeds gpsd from that instead; it + #already no-ops itself for ubx (which gpsd can read directly), so enable it for anything + #else once a receiver has actually been configured (empty receiver_format means none has). + grep -qE "^receiver_format=('ubx'|'')" "${rtkbase_path}"/settings.conf || systemctl enable --now rtkbase_raw2nmea.service echo '################################' echo 'END OF INSTALLATION' echo 'You can open your browser to http://'"$(hostname -I)" diff --git a/tools/lg290p_set_fixed_position.py b/tools/lg290p_set_fixed_position.py new file mode 100644 index 00000000..c72f4b53 --- /dev/null +++ b/tools/lg290p_set_fixed_position.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +lg290p_set_fixed_position.py - automate the LG290P fixed-position base station workflow. + +The LG290P's own internal survey-in is unreliable (confirmed on real hardware and by +independent Quectel forum reports - PQTMCFGSVIN,R can sit at ECEF 0,0,0 for hours without +ever validating). This script sidesteps it entirely: it averages a real position from +rtkbase_raw2nmea's NMEA output (a single-point solution computed directly from the +receiver's own RTCM3 observables, unrelated to the flaky internal survey-in engine), +converts that to WGS84 ECEF, and pushes it to the receiver as a fixed position. + +Requires rtkbase_raw2nmea.service to be running and producing real fixes - enable it first +if it isn't: + sudo systemctl enable --now rtkbase_raw2nmea.service + +Note: this only fixes the position the RECEIVER broadcasts (RTCM 1005). RTKBase's own +"Base coordinates" setting in the web UI is a separate value fed to str2str's -p flag, which +does not reliably produce a working 1005 for this receiver - update both if you want the map +display to match, but this script is what a rover actually needs. + +Also useful once you've logged enough raw data to get a precise coordinate from a +post-processing service (AUSPOS, OPUS, or similar PPP service) - skip the live averaging and +feed that result straight in with --llh or --ecef instead. + +Usage: + ./lg290p_set_fixed_position.py --port /dev/ttyACM0 + ./lg290p_set_fixed_position.py --port /dev/ttyACM0 --duration 600 --yes + ./lg290p_set_fixed_position.py --port /dev/ttyACM0 --ecef -2368021.47 4862841.60 -3368903.03 + ./lg290p_set_fixed_position.py --port /dev/ttyACM0 --llh -32.0900781 115.9643451 9.408 +""" + +import argparse +import math +import socket +import sys +import time + +from lg290p_tool import LG290P, DEFAULT_TIMEOUT, save_params, reboot, wait_for_port, response_ok + +WGS84_A = 6378137.0 +WGS84_F = 1 / 298.257223563 + + +def llh_to_ecef(lat_deg: float, lon_deg: float, h: float): + """WGS84 geodetic (deg, deg, m) -> ECEF X/Y/Z (m).""" + e2 = WGS84_F * (2 - WGS84_F) + lat = math.radians(lat_deg) + lon = math.radians(lon_deg) + n = WGS84_A / math.sqrt(1 - e2 * math.sin(lat) ** 2) + x = (n + h) * math.cos(lat) * math.cos(lon) + y = (n + h) * math.cos(lat) * math.sin(lon) + z = (n * (1 - e2) + h) * math.sin(lat) + return x, y, z + + +def nmea_deg(value: str, direction: str) -> float: + """'3205.40145', 'S' -> -32.0900242 (decimal degrees).""" + if len(value) < 4: + return 0.0 + split = value.find(".") - 2 + deg = float(value[:split]) + minutes = float(value[split:]) + dec = deg + minutes / 60.0 + if direction in ("S", "W"): + dec = -dec + return dec + + +def collect_fixes(host: str, port: int, duration: float, min_fix_quality: int = 1): + """Collect (lat, lon, height) from GGA sentences with a valid fix for `duration` seconds.""" + try: + sock = socket.create_connection((host, port), timeout=10) + except OSError as exc: + sys.exit( + f"Couldn't connect to {host}:{port} ({exc}).\n" + "Is rtkbase_raw2nmea.service running? Enable it with:\n" + " sudo systemctl enable --now rtkbase_raw2nmea.service" + ) + sock.settimeout(2) + + fixes = [] + buf = b"" + deadline = time.time() + duration + last_report = 0.0 + while time.time() < deadline: + try: + chunk = sock.recv(4096) + except socket.timeout: + continue + if not chunk: + sys.exit("Connection to raw2nmea's NMEA stream closed unexpectedly.") + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + line = line.decode("ascii", errors="replace").strip() + if "GGA" not in line: + continue + fields = line.split(",") + if len(fields) < 10: + continue + try: + fix_quality = int(fields[6]) + except ValueError: + continue + if fix_quality < min_fix_quality: + continue + try: + lat = nmea_deg(fields[2], fields[3]) + lon = nmea_deg(fields[4], fields[5]) + height = float(fields[9]) + except (ValueError, IndexError): + continue + fixes.append((lat, lon, height)) + + remaining = deadline - time.time() + if remaining > 0 and time.time() - last_report > 10: + print(f" collecting... {len(fixes)} fixes so far, {remaining:.0f}s remaining", file=sys.stderr) + last_report = time.time() + + sock.close() + return fixes + + +def main(): + ap = argparse.ArgumentParser( + description="Average a real LG290P position and push it as a fixed base-station coordinate.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + ap.add_argument("--port", required=True, help="receiver serial port, e.g. /dev/ttyACM0") + ap.add_argument("--baudrate", type=int, default=460800) + ap.add_argument("--nmea-host", default="localhost", help="rtkbase_raw2nmea host (default: localhost)") + ap.add_argument("--nmea-port", type=int, default=5014, help="rtkbase_raw2nmea NMEA port (default: 5014, matches settings.conf's nmea_port)") + ap.add_argument("--duration", type=float, default=300.0, help="how long to average fixes for, in seconds (default: 300). Ignored if --llh or --ecef is given.") + ap.add_argument("--min-fix-quality", type=int, default=1, help="minimum NMEA GGA fix quality to accept (default: 1, any fix)") + ap.add_argument("--llh", nargs=3, type=float, metavar=("LAT", "LON", "HEIGHT"), + help="skip live averaging, use this WGS84 lat/lon (deg) + ellipsoidal height (m) instead - " + "e.g. from a post-processing service like AUSPOS/OPUS/PPP") + ap.add_argument("--ecef", nargs=3, type=float, metavar=("X", "Y", "Z"), + help="skip live averaging, use this WGS84 ECEF X/Y/Z (m) directly - " + "most PPP post-processing reports include this alongside the LLH result") + ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt") + ap.add_argument("--retry", type=int, default=3) + args = ap.parse_args() + + if args.ecef: + x, y, z = args.ecef + print(f"Using supplied ECEF directly: X={x} Y={y} Z={z}") + elif args.llh: + lat, lon, height = args.llh + x, y, z = llh_to_ecef(lat, lon, height) + print(f"Using supplied LLH: lat={lat} lon={lon} height={height} m") + print(f" -> ECEF: X={x:.4f} Y={y:.4f} Z={z:.4f}") + else: + print(f"Collecting fixes from {args.nmea_host}:{args.nmea_port} for {args.duration:.0f}s...") + fixes = collect_fixes(args.nmea_host, args.nmea_port, args.duration, args.min_fix_quality) + + if not fixes: + sys.exit( + f"No fixes with quality >= {args.min_fix_quality} were received in " + f"{args.duration:.0f}s. Nothing was written to the receiver." + ) + + lat = sum(f[0] for f in fixes) / len(fixes) + lon = sum(f[1] for f in fixes) / len(fixes) + height = sum(f[2] for f in fixes) / len(fixes) + x, y, z = llh_to_ecef(lat, lon, height) + + print(f"\nAveraged {len(fixes)} fixes over {args.duration:.0f}s:") + print(f" LLH: lat={lat:.7f} lon={lon:.7f} height={height:.3f} m") + print(f" ECEF: X={x:.4f} Y={y:.4f} Z={z:.4f}") + + if not args.yes: + answer = input("\nWrite this as the receiver's fixed position? [y/N] ").strip().lower() + if answer != "y": + sys.exit("Aborted, nothing was written.") + + with LG290P(args.port, args.baudrate, DEFAULT_TIMEOUT) as dev: + payload = f"PQTMCFGSVIN,W,2,0,0,{x:.4f},{y:.4f},{z:.4f}" + resp = dev.command(payload, retry=args.retry) + if not response_ok(resp): + sys.exit(f"Failed to set fixed position: {resp}") + print("\nFixed position accepted, saving...") + if not save_params(dev, args.retry): + sys.exit("Failed to save parameters.") + print("Resetting so the new position takes effect (PQTMCFGSVIN needs a reset, a save alone isn't enough)...") + path = dev.port + reboot(dev) + if not wait_for_port(path): + sys.exit("Receiver didn't come back after reset - check the connection.") + + print("\nDone. The receiver should now broadcast this position via RTCM 1005.") + print("Verify with: ./lg290p_tool.py --port " + args.port + " --command raw 'PQTMCFGSVIN,R'") + + +if __name__ == "__main__": + main() diff --git a/tools/lg290p_tool.py b/tools/lg290p_tool.py new file mode 100644 index 00000000..99550847 --- /dev/null +++ b/tools/lg290p_tool.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +""" +lg290p_tool.py - configuration helper for the Quectel LG290P GNSS receiver. + +Standalone version. CLI shape deliberately mirrors rtkbase's tools/unicore_tool.py +and tools/sept_tool.py so it can be dropped into rtkbase later with minimal edits. + +Examples +-------- + # find the receiver + ./lg290p_tool.py --detect + + # identify + ./lg290p_tool.py --port /dev/ttyUSB0 --command get_model + ./lg290p_tool.py --port /dev/ttyUSB0 --command get_firmware + + # factory reset, then push a config file and save it + ./lg290p_tool.py --port /dev/ttyUSB0 --command reset --retry 5 + ./lg290p_tool.py --port /dev/ttyUSB0 --command send_config_file \ + LG290P_rtkbase_rtcm3.cfg --store --retry 3 + +Requires: pyserial (apt install python3-serial) +""" + +import argparse +import glob +import os +import sys +import time + +try: + import serial +except ImportError: + sys.exit("pyserial not found. Install with: sudo apt install python3-serial") + +# LG290P default is 460800; the others are listed in the hardware design doc. +BAUD_RATES = (460800, 115200, 921600, 230400, 9600, 38400, 57600) + +# CH343/CH342 USB-serial bridges used on the Waveshare and SparkFun boards. +# Verify yours with `lsusb` - WCH vendor id is 1a86, product id varies by part. +PORT_GLOBS = ("/dev/ttyUSB*", "/dev/ttyACM*", "/dev/ttyCH343USB*") + +DEFAULT_TIMEOUT = 2.0 +REBOOT_WAIT = 20 # seconds to wait for USB re-enumeration after a reset + + +# -------------------------------------------------------------------------- +# NMEA framing +# -------------------------------------------------------------------------- + +def nmea_checksum(payload: str) -> str: + """XOR of every char between '$' and '*', as two uppercase hex digits.""" + cs = 0 + for ch in payload: + cs ^= ord(ch) + return f"{cs:02X}" + + +def build_sentence(payload: str) -> bytes: + """'PQTMVERNO' -> b'$PQTMVERNO*58\\r\\n'. Accepts payload with or without $.""" + payload = payload.strip().lstrip("$") + if "*" in payload: # caller supplied their own checksum - trust it + return f"${payload}\r\n".encode() + return f"${payload}*{nmea_checksum(payload)}\r\n".encode() + + +def msg_id(payload: str) -> str: + """'PQTMCFGMSGRATE,W,GGA,1' -> 'PQTMCFGMSGRATE'""" + return payload.strip().lstrip("$").split(",")[0].split("*")[0] + + +# -------------------------------------------------------------------------- +# Transport +# -------------------------------------------------------------------------- + +class LG290P: + def __init__(self, port, baudrate=460800, timeout=DEFAULT_TIMEOUT, debug=False): + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.debug = debug + self.ser = None + + def __enter__(self): + self.open() + return self + + def __exit__(self, *exc): + self.close() + + def open(self): + self.ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout) + time.sleep(0.2) + self.ser.reset_input_buffer() + + def close(self): + if self.ser and self.ser.is_open: + self.ser.close() + self.ser = None + + def _log(self, direction, data): + if self.debug: + print(f"{direction} {data!r}", file=sys.stderr) + + def send(self, payload, expect=None, wait=None): + """ + Send one sentence, then read lines until we see a response whose message id + matches (or `expect` if given), or until the timeout expires. + + Returns the matching response line as str, or None. + """ + wait = self.timeout if wait is None else wait + expect = expect or msg_id(payload) + sentence = build_sentence(payload) + + self.ser.reset_input_buffer() + self._log(">>", sentence) + self.ser.write(sentence) + self.ser.flush() + + deadline = time.time() + wait + while time.time() < deadline: + try: + line = self.ser.readline().decode("ascii", errors="replace").strip() + except serial.SerialException: + return None + if not line: + continue + self._log("<<", line) + if line.startswith("$" + expect) or line.startswith(expect): + return line + return None + + def command(self, payload, retry=1, expect=None, wait=None): + """send() with retries. Returns response line or None.""" + for attempt in range(max(1, retry)): + resp = self.send(payload, expect=expect, wait=wait) + if resp is not None: + return resp + if self.debug: + print(f" retry {attempt + 1}", file=sys.stderr) + time.sleep(0.3) + return None + + +def response_ok(resp) -> bool: + """LG290P config writes ack with ',OK'; errors come back as ',ERROR,'.""" + return bool(resp) and ",OK" in resp.upper() + + +def wait_for_port(path, timeout=REBOOT_WAIT): + """After a reset the USB device re-enumerates - wait for the node to reappear.""" + deadline = time.time() + timeout + while time.time() < deadline: + if os.path.exists(path): + time.sleep(1.5) # let udev finish and the receiver finish booting + return True + time.sleep(0.5) + return False + + +# -------------------------------------------------------------------------- +# High level operations +# -------------------------------------------------------------------------- + +def get_version(dev, retry=3): + """Returns the raw PQTMVERNO response, e.g. + $PQTMVERNO,LG290P03AANR01A05S,2024/01/01,12:00:00*XX""" + return dev.command("PQTMVERNO", retry=retry) + + +def get_model(dev, retry=3): + resp = get_version(dev, retry) + if not resp: + return None + fields = resp.split(",") + if len(fields) < 2: + return None + verstr = fields[1] + # verstr looks like LG290P03AANR01A05S - take the family prefix + for family in ("LG290P", "LG580P", "LG680P"): + if verstr.startswith(family): + return family + return verstr + + +def get_firmware(dev, retry=3): + resp = get_version(dev, retry) + if not resp: + return None + fields = resp.split(",") + return fields[1] if len(fields) > 1 else None + + +def factory_reset(dev, retry=3): + """$PQTMRESTOREPAR then reboot. Port will re-enumerate.""" + resp = dev.command("PQTMRESTOREPAR", retry=retry, wait=3) + if not response_ok(resp): + return False + path = dev.port + reboot(dev) + return wait_for_port(path) + + +def save_params(dev, retry=3): + return response_ok(dev.command("PQTMSAVEPAR", retry=retry, wait=3)) + + +def reboot(dev): + """Software reset. Response is unreliable because the device drops off mid-reply.""" + try: + dev.ser.write(build_sentence("PQTMSRR")) + dev.ser.flush() + except serial.SerialException: + pass + time.sleep(0.5) + dev.close() + + +def send_config_file(dev, path, store=False, retry=3): + """ + Config file format - one directive per line: + # comment + PQTMCFGRCVRMODE,W,2 <- checksum computed for you + SLEEP 5 <- pause n seconds + SAVE <- $PQTMSAVEPAR + RESET <- $PQTMSRR + wait for re-enumeration + """ + with open(path, "r") as fh: + lines = [ln.strip() for ln in fh] + + failures = [] + for lineno, line in enumerate(lines, 1): + if not line or line.startswith("#"): + continue + + upper = line.upper() + + if upper.startswith("SLEEP"): + secs = float(line.split()[1]) if len(line.split()) > 1 else 1.0 + print(f" sleeping {secs}s") + time.sleep(secs) + continue + + if upper == "SAVE": + print(" saving parameters") + if not save_params(dev, retry): + failures.append((lineno, "SAVE")) + continue + + if upper == "RESET": + print(" resetting receiver, waiting for re-enumeration") + path_ = dev.port + reboot(dev) + if not wait_for_port(path_): + failures.append((lineno, "RESET (port did not reappear)")) + return failures + dev.open() + continue + + resp = dev.command(line, retry=retry) + status = "ok" if response_ok(resp) else f"FAILED ({resp})" + print(f" {line:<44} {status}") + if not response_ok(resp): + failures.append((lineno, line)) + + if store and not failures: + print(" saving parameters") + if not save_params(dev, retry): + failures.append((0, "final SAVE")) + + return failures + + +def detect(debug=False): + """Scan likely ports and bauds for an LG290P. Returns (port, baud, model, fw).""" + ports = [] + for pattern in PORT_GLOBS: + ports.extend(sorted(glob.glob(pattern))) + + if not ports: + return None + + for port in ports: + for baud in BAUD_RATES: + if debug: + print(f"probing {port} @ {baud}", file=sys.stderr) + try: + with LG290P(port, baud, timeout=0.8, debug=debug) as dev: + resp = get_version(dev, retry=2) + if resp: + fields = resp.split(",") + fw = fields[1] if len(fields) > 1 else "?" + model = get_model(dev, retry=1) or "?" + return (port, baud, model, fw) + except (serial.SerialException, OSError) as exc: + if debug: + print(f" {exc}", file=sys.stderr) + break # port unusable, move to the next one + return None + + +# -------------------------------------------------------------------------- +# CLI +# -------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser( + description="Quectel LG290P configuration tool", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + ap.add_argument("--port", help="serial port, e.g. /dev/ttyUSB0") + ap.add_argument("--baudrate", type=int, default=460800, + help="port speed (default: 460800, the LG290P factory default)") + ap.add_argument("--command", choices=( + "get_model", "get_firmware", "get_version", "get_mode", + "reset", "save", "reboot", "send_config_file", "raw")) + ap.add_argument("argument", nargs="?", + help="config file path for send_config_file, or sentence for raw") + ap.add_argument("--detect", action="store_true", + help="scan for a connected LG290P and print port/baud/model/firmware") + ap.add_argument("--store", action="store_true", + help="save parameters to flash after a successful config file") + ap.add_argument("--retry", type=int, default=3) + ap.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT) + ap.add_argument("--debug", action="store_true", help="echo all traffic to stderr") + args = ap.parse_args() + + if args.detect: + found = detect(debug=args.debug) + if not found: + print("no LG290P found", file=sys.stderr) + return 1 + port, baud, model, fw = found + print(f"{port} - quectel - {baud} - {fw} - {model}") + return 0 + + if not args.port or not args.command: + ap.error("--port and --command are required (or use --detect)") + + try: + with LG290P(args.port, args.baudrate, args.timeout, args.debug) as dev: + if args.command == "get_model": + val = get_model(dev, args.retry) + elif args.command == "get_firmware": + val = get_firmware(dev, args.retry) + elif args.command == "get_version": + val = get_version(dev, args.retry) + elif args.command == "get_mode": + val = dev.command("PQTMCFGRCVRMODE,R", retry=args.retry) + elif args.command == "save": + val = "OK" if save_params(dev, args.retry) else None + elif args.command == "reboot": + reboot(dev) + val = "OK" if wait_for_port(args.port) else None + elif args.command == "reset": + val = "OK" if factory_reset(dev, args.retry) else None + elif args.command == "raw": + if not args.argument: + ap.error("raw needs a sentence, e.g. 'PQTMCFGRCVRMODE,R'") + val = dev.command(args.argument, retry=args.retry) + elif args.command == "send_config_file": + if not args.argument: + ap.error("send_config_file needs a file path") + failures = send_config_file(dev, args.argument, args.store, args.retry) + if failures: + print(f"\n{len(failures)} command(s) failed:", file=sys.stderr) + for lineno, cmd in failures: + print(f" line {lineno}: {cmd}", file=sys.stderr) + return 1 + print("\nconfiguration applied successfully") + return 0 + + if val is None: + print("no response from receiver", file=sys.stderr) + return 1 + print(val) + return 0 + + except serial.SerialException as exc: + print(f"serial error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/rtcm_sniff.py b/tools/rtcm_sniff.py new file mode 100644 index 00000000..7c07202a --- /dev/null +++ b/tools/rtcm_sniff.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +rtcm_sniff.py - count RTCM3 message types on a serial port or file. + +Frames are validated with CRC-24Q before being counted, so a desync in the +byte stream cannot invent message ids. Also reports NMEA talker sentences. + + ./rtcm_sniff.py /dev/ttyACM0 460800 --seconds 60 +""" + +import argparse +import collections +import sys +import time + +try: + import serial +except ImportError: + sys.exit("pyserial not found. Install with: sudo apt install python3-serial") + + +def crc24q(data: bytes) -> int: + """RTCM3 CRC-24Q, polynomial 0x1864CFB.""" + crc = 0 + for byte in data: + crc ^= byte << 16 + for _ in range(8): + crc <<= 1 + if crc & 0x1000000: + crc ^= 0x1864CFB + return crc & 0xFFFFFF + + +def scan(buf: bytearray, rtcm: collections.Counter): + """Consume complete, CRC-valid RTCM3 frames from the head of buf.""" + while True: + i = buf.find(b"\xd3") + if i < 0: + buf.clear() + return + if len(buf) - i < 6: + del buf[:i] + return + length = ((buf[i + 1] & 0x03) << 8) | buf[i + 2] + end = i + 3 + length + 3 + if len(buf) < end: + del buf[:i] + return + frame = bytes(buf[i:end]) + payload, crc = frame[:-3], frame[-3:] + if crc24q(payload) == int.from_bytes(crc, "big"): + if length >= 2: + rtcm[(frame[3] << 4) | (frame[4] >> 4)] += 1 + del buf[:end] + else: + # not a real frame - skip this D3 and resync + del buf[:i + 1] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("port") + ap.add_argument("baudrate", nargs="?", type=int, default=460800) + ap.add_argument("--seconds", type=float, default=30.0) + args = ap.parse_args() + + rtcm = collections.Counter() + nmea = collections.Counter() + buf = bytearray() + line = bytearray() + + with serial.Serial(args.port, args.baudrate, timeout=1) as ser: + deadline = time.time() + args.seconds + while time.time() < deadline: + chunk = ser.read(4096) + if not chunk: + continue + buf += chunk + for b in chunk: + if b == 0x24: # '$' + line = bytearray(b"$") + elif line: + if b in (10, 13): + if len(line) > 6: + nmea[line[:6].decode("ascii", "replace")] += 1 + line = bytearray() + else: + line.append(b) + scan(buf, rtcm) + + total = sum(rtcm.values()) + print(f"RTCM3 frames (CRC-valid): {total} in {args.seconds:g}s") + for k, v in sorted(rtcm.items()): + print(f" {k:5d} x{v}") + if nmea: + print("NMEA:") + for k, v in sorted(nmea.items()): + print(f" {k} x{v}") + return 0 if total else 1 + + +if __name__ == "__main__": + sys.exit(main())