From 247a9704ba0e8f82b7cb3e679e77b81268a46ebe Mon Sep 17 00:00:00 2001 From: Alastair D'Silva Date: Tue, 26 Aug 2025 09:07:26 +1000 Subject: [PATCH 1/2] Add support for Quectel LC29H-BS Signed-off-by: Alastair D'Silva --- configure_gps.sh | 15 +++ receiver_cfg/LC29HBS_Configure.txt | 35 +++++++ receiver_cfg/LC29HBS_Factory_Defaults.txt | 2 + receiver_cfg/LC29HBS_Reboot.txt | 9 ++ receiver_cfg/LC29HBS_Save.txt | 2 + receiver_cfg/LC29HBS_Set_Baud.txt | 2 + receiver_cfg/LC29HBS_Version.txt | 2 + tools/install.sh | 38 +++++++- tools/nmea.py | 107 ++++++++++++++++++++++ tools/uninstall.sh | 3 +- unit/configure_gps.service | 19 ++++ web_app/server.py | 3 +- 12 files changed, 231 insertions(+), 6 deletions(-) create mode 100755 configure_gps.sh create mode 100644 receiver_cfg/LC29HBS_Configure.txt create mode 100644 receiver_cfg/LC29HBS_Factory_Defaults.txt create mode 100644 receiver_cfg/LC29HBS_Reboot.txt create mode 100644 receiver_cfg/LC29HBS_Save.txt create mode 100644 receiver_cfg/LC29HBS_Set_Baud.txt create mode 100644 receiver_cfg/LC29HBS_Version.txt create mode 100644 tools/nmea.py create mode 100644 unit/configure_gps.service diff --git a/configure_gps.sh b/configure_gps.sh new file mode 100755 index 00000000..b06543d8 --- /dev/null +++ b/configure_gps.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# +# configure_gps.sh: script to provide GPS modules with commands +# that are not saved in flash on the module (ie. they must be provided +# each time the module is started). + + +BASEDIR="$(dirname "$0")" +source <( grep -v '^#' "${BASEDIR}"/settings.conf | grep '=' ) #import settings + +if [[ "${receiver}" = "Quectel LC29HBS" ]]; then + speed="${com_port_settings%%:*}" + python3 "${BASEDIR}"/tools/nmea.py --file "${BASEDIR}"/receiver_cfg/LC29HBS_Configure.txt /dev/"${com_port}" "${speed}" 3 + echo Configuring Quectel LC29HBS on /dev/"${com_port}" at speed "${speed}" +fi diff --git a/receiver_cfg/LC29HBS_Configure.txt b/receiver_cfg/LC29HBS_Configure.txt new file mode 100644 index 00000000..897637c7 --- /dev/null +++ b/receiver_cfg/LC29HBS_Configure.txt @@ -0,0 +1,35 @@ +#Enable MSM7 messages +$PAIR432,1 + +#Enable Station Reference Message 1005 +$PAIR434,1 + +#Enable Ephemeris messages +$PAIR436,1 + +#Enable NMEA GGA Time, position, and fix related data +$PAIR062,0,1 + +#Enable NMEA GLL Position data: position fix, time of position fix, and status +$PAIR062,1,1 + +#Enable NMEA GSA GPS DOP and active satellites +$PAIR062,2,1 + +#Enable NMEA GSV Satellite information +$PAIR062,3,1 + +#Enable NMEA RMC Position, velocity, and time +$PAIR062,4,1 + +#Enable NMEA VTG Track made good and speed over ground +$PAIR062,5,1 + +#Enable NMEA ZDA UTC day, month, and year, and local time zone offset +$PAIR062,6,1 + +#Enable NMEA GRS GRS range residuals +$PAIR062,7,1 + +#Enable NMEA GST Position error statistics +$PAIR062,8,1 diff --git a/receiver_cfg/LC29HBS_Factory_Defaults.txt b/receiver_cfg/LC29HBS_Factory_Defaults.txt new file mode 100644 index 00000000..fa51a689 --- /dev/null +++ b/receiver_cfg/LC29HBS_Factory_Defaults.txt @@ -0,0 +1,2 @@ +# Restore factory defaults +$PQTMRESTOREPAR diff --git a/receiver_cfg/LC29HBS_Reboot.txt b/receiver_cfg/LC29HBS_Reboot.txt new file mode 100644 index 00000000..b097d5fe --- /dev/null +++ b/receiver_cfg/LC29HBS_Reboot.txt @@ -0,0 +1,9 @@ +#Power off the GNSS +$PAIR003 + +#SLEEP# 1000 + +#Power on the GNSS +$PAIR002 + +#SLEEP# 5000 diff --git a/receiver_cfg/LC29HBS_Save.txt b/receiver_cfg/LC29HBS_Save.txt new file mode 100644 index 00000000..2e1fe977 --- /dev/null +++ b/receiver_cfg/LC29HBS_Save.txt @@ -0,0 +1,2 @@ +#Save parameters +$PQTMSAVEPAR diff --git a/receiver_cfg/LC29HBS_Set_Baud.txt b/receiver_cfg/LC29HBS_Set_Baud.txt new file mode 100644 index 00000000..000b7483 --- /dev/null +++ b/receiver_cfg/LC29HBS_Set_Baud.txt @@ -0,0 +1,2 @@ +#Crank the baud rate up (Could also go 3000000) +$PAIR864,0,0,921600 diff --git a/receiver_cfg/LC29HBS_Version.txt b/receiver_cfg/LC29HBS_Version.txt new file mode 100644 index 00000000..216e921c --- /dev/null +++ b/receiver_cfg/LC29HBS_Version.txt @@ -0,0 +1,2 @@ +# Get the model and firmware version +$PQTMVERNO diff --git a/tools/install.sh b/tools/install.sh index cf97628a..83b2269c 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -407,7 +407,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 @@ -426,13 +426,23 @@ detect_gnss() { systemctl is-active --quiet str2str_tcp.service && sudo systemctl stop str2str_tcp.service && echo 'Stopping str2str_tcp service' # TODO remove port if not available in /dev/ for port in ttyS0 ttyUSB0 ttyUSB1 ttyUSB2 serial0 ttyS1 ttyS2 ttyS3 ttyS4 ttyS5; do - for port_speed in 115200 57600 38400 19200 9600; do + for port_speed in 3000000 921600 115200 57600 38400 19200 9600; do echo 'DETECTION ON ' $port ' at ' $port_speed + # Detect u-blox ZED-F9P receivers if [[ $(python3 "${rtkbase_path}"/tools/ubxtool -f /dev/$port -s $port_speed -p MON-VER -w 5 2>/dev/null) =~ 'ZED-F9P' ]]; then detected_gnss[0]=$port detected_gnss[1]='u-blox' detected_gnss[2]=$port_speed - #echo 'U-blox ZED-F9P DETECTED ON '$port $port_speed + #echo 'U-blox ZED-F9P DETECTED ON ' $port ' at ' $port_speed + break + fi + + # Detect Quectel LC29H-BS receivers using nmea.py + if [[ $(python3 "${rtkbase_path}"/tools/nmea.py --file "${rtkbase_path}"/receiver_cfg/LC29HBS_Version.txt /dev/$port $port_speed 3 2>/dev/null) =~ 'LC29HBS' ]]; then + detected_gnss[0]=$port + detected_gnss[1]='LC29H-BS' + detected_gnss[2]=$port_speed + #echo 'Quectel LC29H-BS DETECTED ON ' $port ' at ' $port_speed break elif { model=$(python3 "${rtkbase_path}"/tools/unicore_tool.py --port /dev/$port --baudrate $port_speed --command get_model 2>/dev/null) ; [[ "${model}" == 'UM98'[0-2] ]] ;}; then detected_gnss[0]=$port @@ -594,7 +604,26 @@ configure_gnss(){ echo 'Failed to configure the Gnss receiver' return 1 fi - + elif [[ $(python3 "${rtkbase_path}"/tools/nmea.py --file "${rtkbase_path}"/receiver_cfg/LC29HBS_Version.txt /dev/"${com_port}" ${com_port_settings%%:*} 3 2>/dev/null) =~ 'LC29HBS' ]]; then + # Factory reset and configure the module + python3 "${rtkbase_path}"/tools/nmea.py --verbose --file "${rtkbase_path}"/receiver_cfg/LC29HBS_Factory_Defaults.txt /dev/"${com_port}" ${com_port_settings%%:*} 3 >>"${rtkbase_path}"/logs/LC29HBS_Configure.log && \ + python3 "${rtkbase_path}"/tools/nmea.py --verbose --file "${rtkbase_path}"/receiver_cfg/LC29HBS_Set_Baud.txt /dev/"${com_port}" ${com_port_settings%%:*} 3 >>"${rtkbase_path}"/logs/LC29HBS_Configure.log && \ + python3 "${rtkbase_path}"/tools/nmea.py --verbose --file "${rtkbase_path}"/receiver_cfg/LC29HBS_Save.txt /dev/"${com_port}" ${com_port_settings%%:*} 3 >>"${rtkbase_path}"/logs/LC29HBS_Configure.log && \ + python3 "${rtkbase_path}"/tools/nmea.py --verbose --file "${rtkbase_path}"/receiver_cfg/LC29HBS_Reboot.txt /dev/"${com_port}" ${com_port_settings%%:*} 3 >>"${rtkbase_path}"/logs/LC29HBS_Configure.log && \ + + # Speed has now been configured to 921600 + speed=921600 + version_str="$(python3 "${rtkbase_path}"/tools/nmea.py --file "${rtkbase_path}"/receiver_cfg/LC29HBS_Version.txt /dev/"${com_port}" ${speed} 3 2>/dev/null)" + firmware="`echo "$version_str" | cut -d , -f 2`" + if [[ -z "$version_str" ]]; then + echo "Could not get LC29HBS version string after rebooting the module, try power cycling the module." + return 1 + fi + sudo -u "${RTKBASE_USER}" sed -i s/^receiver_firmware=.*/receiver_firmware=\'${firmware}\'/ "${rtkbase_path}"/settings.conf && \ + sudo -u "${RTKBASE_USER}" sed -i s/^com_port_settings=.*/com_port_settings=\'921600:8:n:1\'/ "${rtkbase_path}"/settings.conf && \ + sudo -u "${RTKBASE_USER}" sed -i s/^receiver=.*/receiver=\'Quectel LC29HBS\'/ "${rtkbase_path}"/settings.conf && \ + sudo -u "${RTKBASE_USER}" sed -i s/^receiver_format=.*/receiver_format=\'rtcm3\'/ "${rtkbase_path}"/settings.conf + return $? else echo 'No Gnss receiver has been set. We can'\''t configure' return 1 @@ -665,6 +694,7 @@ start_services() { systemctl daemon-reload systemctl enable --now rtkbase_web.service systemctl enable --now str2str_tcp.service + systemctl enable --now configure_gps.service systemctl restart gpsd.service systemctl restart chrony.service systemctl enable --now rtkbase_archive.timer diff --git a/tools/nmea.py b/tools/nmea.py new file mode 100644 index 00000000..93baf747 --- /dev/null +++ b/tools/nmea.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 + +import argparse +import serial +import time + +# Function to calculate NMEA checksum +def calculate_nmea_checksum(nmea_sentence): + checksum = 0 + # Iterate through each character after the starting '$' and before '*' + for char in nmea_sentence[1:]: + checksum ^= ord(char) + return f"{nmea_sentence}*{checksum:02X}" + +# Function to append checksum if not provided +def append_checksum_if_missing(nmea_sentence): + if '*' not in nmea_sentence: + # Calculate and append checksum if '*' is missing + return calculate_nmea_checksum(nmea_sentence) + return nmea_sentence + +# Function to handle serial communication +def send_nmea_command(port, speed, timeout, nmea_command, verbose): + # Open serial port + try: + with serial.Serial(port, baudrate=speed, timeout=timeout) as ser: + # Append checksum if missing + nmea_command_with_checksum = append_checksum_if_missing(nmea_command) + + # Write NMEA command to the serial port + ser.write((nmea_command_with_checksum + '\r\n').encode('ascii')) + if verbose: + print(f"Sent command: {nmea_command_with_checksum}") + + # Wait for the response + start_time = time.time() + while time.time() - start_time < timeout: + try: + response = ser.readline().decode('ascii', errors='ignore').strip() + # Process only valid ASCII responses + if response and response.startswith('$'): + if verbose: + print(f"Received response: {response}") + return response + except UnicodeDecodeError as e: + # Skip non-ASCII responses (likely RTCM3 messages) + if verbose: + print(f"Non-ASCII data skipped: {e}") + if verbose: + print("Timeout: No matching response received.") + except serial.SerialException as e: + print(f"Error opening serial port: {e}") + +# Function to read NMEA commands from a file and ignore lines starting with '#' and blank lines +def read_commands_from_file(file_path): + try: + with open(file_path, 'r') as file: + commands = [] + for line in file: + line = line.strip() + # Ignore blank lines and lines starting with '#' + if line and not line.startswith('#') or line.startswith('#SLEEP#'): + commands.append(line) + return commands + except FileNotFoundError: + print(f"Error: File '{file_path}' not found.") + return [] + +# Function to handle the sleep command in the file +def handle_sleep_command(command, verbose): + try: + sleep_time_ms = int(command.split('#SLEEP# ')[1]) + if verbose: + print(f"Sleeping for {sleep_time_ms} ms") + time.sleep(sleep_time_ms / 1000) # Convert to seconds + except (IndexError, ValueError): + print(f"Invalid sleep command format: {command}") + +if __name__ == "__main__": + # Parse command line arguments + parser = argparse.ArgumentParser(description="Send NMEA commands to Quectel LC29H module") + parser.add_argument('port', type=str, help='Serial port to use (e.g., /dev/ttyUSB0 or COM3)') + parser.add_argument('speed', type=int, help='Baud rate (e.g., 9600)') + parser.add_argument('timeout', type=int, help='Timeout in seconds') + parser.add_argument('command', nargs='?', type=str, help='NMEA command to send (optional, overrides file)') + parser.add_argument('--file', type=str, help='File with NMEA commands to send') + parser.add_argument('--verbose', action='store_true', help='Enable verbose output for tracing') + + args = parser.parse_args() + + # Determine which commands to send (from file or argument) + if args.command: + # Send the provided command as an argument + nmea_commands = [args.command] + elif args.file: + # Read commands from the file, ignoring comments and blank lines + nmea_commands = read_commands_from_file(args.file) + else: + print("Error: You must provide either a command or a file containing commands.") + exit(1) + + # Send each NMEA command from the list + for command in nmea_commands: + if command.startswith('#SLEEP#'): + handle_sleep_command(command, args.verbose) + else: + send_nmea_command(args.port, args.speed, args.timeout, command, args.verbose) diff --git a/tools/uninstall.sh b/tools/uninstall.sh index 37d86d40..0d587a46 100755 --- a/tools/uninstall.sh +++ b/tools/uninstall.sh @@ -18,7 +18,8 @@ for service_name in str2str_tcp.service \ rtkbase_archive.timer \ modem_check.service \ modem_check.timer \ - rtkbase_gnss_web_proxy.service + rtkbase_gnss_web_proxy.service \ + configure_gps.service do echo 'Deleting ' "${service_name}" systemctl stop "${service_name}" diff --git a/unit/configure_gps.service b/unit/configure_gps.service new file mode 100644 index 00000000..1f7861b0 --- /dev/null +++ b/unit/configure_gps.service @@ -0,0 +1,19 @@ +[Unit] +Description=Configure GPS +Before=str2str_tcp.service +#After=network-online.target +#Wants=network-online.target +#Requires=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +User={user} +ExecStart={script_path}/configure_gps.sh +Restart=no +ProtectHome=read-only +ProtectSystem=strict +ReadWritePaths={script_path} + +[Install] +WantedBy=multi-user.target diff --git a/web_app/server.py b/web_app/server.py index 74b6b2a1..fe44332c 100755 --- a/web_app/server.py +++ b/web_app/server.py @@ -108,7 +108,8 @@ {'service_unit' : 'rtkbase_archive.timer', "name" : "archive_timer"}, {'service_unit' : 'rtkbase_archive.service', "name" : "archive_service"}, {'service_unit' : 'rtkbase_raw2nmea.service', "name" : "raw2nmea"}, - {'service_unit' : 'rtkbase_gnss_web_proxy.service', "name": "RTKBase Reverse Proxy for Gnss receiver Web Server"} + {'service_unit' : 'rtkbase_gnss_web_proxy.service', "name": "RTKBase Reverse Proxy for Gnss receiver Web Server"}, + {'service_unit' : 'configure_gps.service', "name" : "configure_gps"}, ] #Delay before rtkrcv will stop if no user is on status.html page From d8f6edc5e98a02071ed52f815779ca95aa10356f Mon Sep 17 00:00:00 2001 From: Alastair D'Silva Date: Tue, 26 Aug 2025 09:19:07 +1000 Subject: [PATCH 2/2] A helper tool for surveying in the LC29HBS RTK GPS It initiates survey-in on the device, and also calculates and displays the average geodetic coordinates to manually set the base station location in RTKBase Example run: python3 lc29h-bs_survey.py --mode survey --min-dur 86400 --speed 921600 /dev/ttyS0 --- README.md | 11 +- tools/lc29h-bs_survey.py | 236 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 tools/lc29h-bs_survey.py diff --git a/README.md b/README.md index f50f545a..8f74591f 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,13 @@ If you use a Raspberry Pi, thanks to [jancelin](https://github.com/jancelin), yo - [rtklibexplorer - PPP - for dual frequency receivers](https://rtklibexplorer.wordpress.com/2017/11/23/ppp-solutions-with-the-swiftnav-piksi-multi/) - [Centipede documentation (in french)](https://docs.centipede.fr/docs/base/positionnement.html) + For Quectel LC29HBS receivers, open a shell, and run the following to survey-in the device (averaging samples across 1 day), and display the base station coordinates: + ```bash + python3 tools/lc29h-bs_survey.py --mode survey --min-dur 86400 --speed 921600 /dev/ttyS0 + ``` + + To help you find your base ip address, you can use the simple `find_rtkase` gui tool. It is available for Gnu/Linux and Windows in [./tools/find_rtkbase/dist](./tools/find_rtkbase/dist/). - + - Click on the "Find" button, wait, then click on the "Open" button. It will open the RTKBase GUI in your web browser. screenshot of find_rtkbase tool @@ -195,9 +200,9 @@ So, if you really want it, let's go for a manual installation with some explanat ``` 1. Install and configure chrony and gpsd with `sudo ./install.sh --gpsd-chrony`, or: + Install chrony with `sudo apt install chrony` then add this parameter in the chrony conf file (/etc/chrony/chrony.conf): - + ```refclock SHM 0 refid GPS precision 1e-1 offset 0.2 delay 0.2``` - + Edit the chrony unit file. You should set `After=gpsd.service` + Install a gpsd release >= 3.2 or it won't work with a F9P. Its conf file should contains: ``` diff --git a/tools/lc29h-bs_survey.py b/tools/lc29h-bs_survey.py new file mode 100644 index 00000000..bbe033a4 --- /dev/null +++ b/tools/lc29h-bs_survey.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 + +import argparse +import serial +import time +import re +import sys +from pyproj import Transformer + +# Terminal colour codes +class Colour: + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' + RED = '\033[91m' + YELLOW = '\033[93m' + GREEN = '\033[92m' + +# Accuracy thresholds (constants for easier adjustment) +ACCURACY_THRESHOLD_1M = 1.0 +ACCURACY_THRESHOLD_10CM = 0.1 + +# Common baud rates to try for auto-detection +BAUD_RATES = [9600, 19200, 38400, 57600, 115200, 921600] + +def calculate_nmea_checksum(nmea_sentence: str) -> str: + checksum = 0 + for char in nmea_sentence[1:]: + checksum ^= ord(char) + return f"{nmea_sentence}*{checksum:02X}" + +def append_checksum_if_missing(nmea_sentence: str) -> str: + if '*' not in nmea_sentence: + return calculate_nmea_checksum(nmea_sentence) + return nmea_sentence + +def read_gps_messages(port: str, speed: int, timeout: int, verbose: bool = False): + try: + with serial.Serial(port, baudrate=speed, timeout=timeout) as ser: + while True: + response = ser.readline().decode('ascii', errors='ignore').strip() + if response and response.startswith('$'): + if verbose: + print(f"{Colour.OKBLUE}Received message: {response}{Colour.ENDC}") + yield response + except serial.SerialException as e: + print(f"Error opening serial port: {e}") + +def ecef_to_geodetic(x: float, y: float, z: float) -> tuple: + transformer = Transformer.from_crs("EPSG:4978", "EPSG:4326", always_xy=True) + lon, lat, alt = transformer.transform(x, y, z) + return lat, lon, alt + +def colour_diff(current: str, previous: str) -> str: + """ + Compare digits in the current and previous strings. Mark changed digits in red. + """ + result = [] + change_detected = False + for c, p in zip(current, previous): + if c.isdigit() and p.isdigit(): + if change_detected or c != p: + result.append(f"{Colour.RED}{c}{Colour.ENDC}") + change_detected = True + else: + result.append(f"{Colour.GREEN}{c}{Colour.ENDC}") + else: + result.append(c) + change_detected = False + if len(current) > len(previous): + for c in current[len(previous):]: + result.append(f"{Colour.RED}{c}{Colour.ENDC}" if c.isdigit() else c) + return ''.join(result) + +def redraw_terminal(lines: list, reset_cursor: bool): + """ + Redraw lines in the terminal. If reset_cursor is True, reset cursor position for updates. + If False, simply print lines without resetting the cursor. + """ + if sys.stdout.isatty(): + if reset_cursor: + sys.stdout.write('\033[F' * len(lines)) # Reset the cursor for updates + for line in lines: + sys.stdout.write('\033[K' + line + '\n') # Print each line + +def colourise_accuracy(accuracy: float) -> str: + """Colour accuracy based on its value.""" + if accuracy > ACCURACY_THRESHOLD_1M: + return f"{Colour.RED}{accuracy:.2f}{Colour.ENDC} metres" + elif accuracy >= ACCURACY_THRESHOLD_10CM: + return f"{Colour.YELLOW}{accuracy:.2f}{Colour.ENDC} metres" + else: + return f"{Colour.GREEN}{accuracy:.2f}{Colour.ENDC} metres" + +def start_survey_in(port: str, speed: int, timeout: int, min_dur: int, acc_limit: float, verbose: bool): + command = f"$PQTMCFGSVIN,W,1,{min_dur},{acc_limit},0.0,0.0,0.0" + send_nmea_command(port, speed, command, timeout, verbose) + + print(Colour.HEADER + Colour.BOLD + "Starting survey-in..." + Colour.ENDC) + + start_time = time.time() + prev_ecef = None + prev_geo = None + prev_latlon = None # Cache to avoid unnecessary recalculations + reset_cursor = False # Track whether to reset the cursor + + for response in read_gps_messages(port, speed, timeout, verbose): + match = re.match(r"\$PQTMSVINSTATUS,\d+,\d+,(\d+),,\d+,\d+,\d+,(-?\d+\.\d+),(-?\d+\.\d+),(-?\d+\.\d+),(\d+\.\d+)\*\w+", response) + if match: + valid_flag = int(match.group(1)) + mean_x, mean_y, mean_z = map(float, match.group(2, 3, 4)) + mean_acc = float(match.group(5)) + obs_count = int(response.split(',')[6]) + elapsed_time = int(time.time() - start_time) + remaining_time = max(0, int(min_dur - elapsed_time)) + + # Convert ECEF to geodetic only if ECEF coordinates have changed + if prev_ecef != (mean_x, mean_y, mean_z): + lat, lon, alt = ecef_to_geodetic(mean_x, mean_y, mean_z) + prev_latlon = f"Lat={lat:.7f}, Lon={lon:.7f}, Alt={alt:.3f}" + + current_ecef = f"X={mean_x:.4f}, Y={mean_y:.4f}, Z={mean_z:.4f}" + + if prev_ecef: + colourised_ecef = colour_diff(current_ecef, f"X={prev_ecef[0]:.4f}, Y={prev_ecef[1]:.4f}, Z={prev_ecef[2]:.4f}") + colourised_geo = colour_diff(prev_latlon, prev_geo) + else: + colourised_ecef = current_ecef + colourised_geo = prev_latlon + + prev_ecef = (mean_x, mean_y, mean_z) + prev_geo = prev_latlon + + coloured_accuracy = colourise_accuracy(mean_acc) + + if valid_flag == 2: + print(Colour.OKGREEN + "Survey-in complete." + Colour.ENDC) + print(f"Final {Colour.BOLD}Accuracy{Colour.ENDC}: {coloured_accuracy}") + print(f"Final {Colour.BOLD}ECEF{Colour.ENDC}: {current_ecef}") + print(f"Final {Colour.BOLD}Geodetic{Colour.ENDC}: {prev_latlon}") + break + elif valid_flag == 1: + lines_to_display = [ + f"{Colour.WARNING}Survey-in in progress{Colour.ENDC}: {Colour.BOLD}Elapsed{Colour.ENDC}: {elapsed_time} seconds, {Colour.BOLD}Remaining{Colour.ENDC}: {remaining_time} seconds, {Colour.BOLD}Accuracy{Colour.ENDC}: {coloured_accuracy}, {Colour.BOLD}Observations{Colour.ENDC}: {obs_count}", + f"{Colour.HEADER}{Colour.BOLD}ECEF{Colour.ENDC}: {colourised_ecef}", + f"{Colour.HEADER}{Colour.BOLD}Geodetic{Colour.ENDC}: {colourised_geo}" + ] + redraw_terminal(lines_to_display, reset_cursor) + reset_cursor = True # Reset cursor for future updates + time.sleep(1) + +def send_nmea_command(port: str, speed: int, nmea_command: str, timeout: int, verbose: bool = False) -> str: + try: + with serial.Serial(port, baudrate=speed, timeout=timeout) as ser: + nmea_command_with_checksum = append_checksum_if_missing(nmea_command) + ser.write((nmea_command_with_checksum + '\r\n').encode('ascii')) + if verbose: + print(f"Sent command: {Colour.OKBLUE}{nmea_command_with_checksum}{Colour.ENDC}") + + response = ser.readline().decode('ascii', errors='ignore').strip() + if response: + if verbose: + print(f"Received response: {Colour.OKBLUE}{response}{Colour.ENDC}") + return response + except serial.SerialException as e: + print(f"Error opening serial port: {e}") + +def detect_speed(port: str, timeout: int, verbose: bool = False) -> int: + command = "$PQTMVERNO" + command_with_checksum = append_checksum_if_missing(command) + for speed in BAUD_RATES: + if verbose: + print(f"Trying baud rate {speed}...") + try: + with serial.Serial(port, baudrate=speed, timeout=timeout) as ser: + ser.write((command_with_checksum + '\r\n').encode('ascii')) + if verbose: + print(f"Sent command: {Colour.OKBLUE}{command_with_checksum}{Colour.ENDC}") + response = ser.readline().decode('ascii', errors='ignore').strip() + if response.startswith("$PQTMVERNO"): + if verbose: + print(f"{Colour.OKGREEN}Received response at {speed} baud: {response}{Colour.ENDC}") + return speed + except serial.SerialException: + if verbose: + print(f"{Colour.FAIL}Failed to open serial port at {speed} baud.{Colour.ENDC}") + raise Exception("Failed to detect baud rate. No valid response for PQTMVERNO command.") + +def disable_survey_in(port: str, speed: int, timeout: int, verbose: bool = False): + command = "$PQTMCFGSVIN,W,0,0,0.0,0.0,0.0,0.0" + send_nmea_command(port, speed, command, timeout, verbose) + print(Colour.OKGREEN + "Survey-in disabled." + Colour.ENDC) + +def set_fixed_mode(port: str, speed: int, ecef_x: float, ecef_y: float, ecef_z: float, timeout: int, verbose: bool = False): + command = f"$PQTMCFGSVIN,W,2,0,0.0,{ecef_x},{ecef_y},{ecef_z}" + response = send_nmea_command(port, speed, command, timeout, verbose) + if response and "OK" in response: + print(Colour.OKGREEN + "Fixed mode set successfully." + Colour.ENDC) + else: + print(Colour.FAIL + "Failed to set fixed mode." + Colour.ENDC) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Survey-in and Fixed mode tool for Quectel LC29H-BS GPS module.") + parser.add_argument('port', type=str, help='Serial port to use (e.g., /dev/ttyUSB0 or COM3)') + parser.add_argument('--timeout', type=int, default=3, help='Timeout in seconds for GPS response (default: 3 seconds)') + parser.add_argument('--speed', type=int, help='Baud rate (e.g., 9600). If not provided, the script will attempt to detect the speed.') + parser.add_argument('--mode', type=str, choices=['survey', 'fixed', 'disable'], required=True, help="Select mode: 'survey', 'fixed', or 'disable'") + parser.add_argument('--ecef', nargs=3, type=float, help="ECEF coordinates (X Y Z) for fixed mode") + parser.add_argument('--min-dur', type=int, default=86400, help="Minimum duration for survey-in mode (default: 86400 seconds / 1 day)") + parser.add_argument('--acc-limit', type=float, default=15.0, help="Accuracy limit for survey-in mode in metres (default: 15 metres)") + parser.add_argument('--verbose', action='store_true', help='Enable verbose output') + + args = parser.parse_args() + + if args.speed: + speed = args.speed + else: + speed = detect_speed(args.port, args.timeout, args.verbose) + print(f"Detected speed: {speed} baud") + + if args.mode == 'disable': + disable_survey_in(args.port, speed, args.timeout, args.verbose) + elif args.mode == 'survey': + start_survey_in(args.port, speed, args.timeout, args.min_dur, args.acc_limit, args.verbose) + elif args.mode == 'fixed': + if not args.ecef: + print(Colour.FAIL + "Error: You must provide ECEF coordinates for fixed mode." + Colour.ENDC) + exit(1) + ecef_x, ecef_y, ecef_z = args.ecef + set_fixed_mode(args.port, speed, ecef_x, ecef_y, ecef_z, args.timeout, args.verbose) +