Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions receiver_cfg/LG290P_rtkbase_rtcm3.cfg
Original file line number Diff line number Diff line change
@@ -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 <secs> | SAVE | RESET | <PQTM payload>
#
# --- 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
76 changes: 75 additions & 1 deletion tools/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]}"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand Down
Loading