diff --git a/.gitignore b/.gitignore index 5d11de82..6b9be030 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ venv/ platformio.local.ini CODE_REVIEW.md tools/pngs/ +tools/gpx/ diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index daef1aa4..f80c23dc 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -100,13 +100,20 @@ Cycle views with **LEFT / RIGHT**: ### Downloading GPX -1. Connect the device via USB cable -2. Open a serial terminal at **115200 baud**: - - **macOS/Linux** — `cat /dev/tty.usbmodem* > track.gpx` (stop with Ctrl-C after dump completes) - - **Windows** — PuTTY (Serial, 115200) or Arduino IDE Serial Monitor (no line ending) -3. On the device: **Tools › Trail** → **Hold Enter** → **Export (live)** or **Export (saved)** -4. The device streams a GPX 1.1 XML document. Save the output from `` as a `.gpx` file -5. Import into OsmAnd, Garmin BaseCamp, GPX Studio, Google Earth, etc. +**Recommended — `tools/trail_export.py`** (auto-detects the port, captures from ``, writes a timestamped file under `tools/gpx/`): + +```sh +uv run tools/trail_export.py +``` + +Then on the device: **Tools › Trail** → **Hold Enter** → **Export (live)** or **Export (saved)**. + +**Manual fallback** — open a serial terminal at **115200 baud** and capture the stream by hand: + +- **macOS/Linux** — `cat /dev/tty.usbmodem* > track.gpx` (stop with Ctrl-C after the dump finishes) +- **Windows** — PuTTY (Serial, 115200) or Arduino IDE Serial Monitor with no line ending; copy the text from `` into a `.gpx` file + +Either way, the resulting file imports into OsmAnd, Garmin BaseCamp, GPX Studio, Google Earth, etc. > [!NOTE] > If the companion app is connected via **BLE**, the export is safe — BLE and USB operate independently. If connected via **USB**, disconnect the app before exporting. diff --git a/tools/trail_export.py b/tools/trail_export.py new file mode 100644 index 00000000..78ce8144 --- /dev/null +++ b/tools/trail_export.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +Capture a GPX track from a Wio L1 Tracker (Solo firmware) over USB serial. + +Usage: + uv run tools/trail_export.py [--port PORT] [--out OUT] + +The device prints raw GPX 1.1 XML to the USB-CDC serial port when the user +selects Tools › Trail › Hold Enter › Export (live) or Export (saved). +This script waits for the ``, +and writes them to a timestamped file in `tools/gpx/`. + +> Tip: disconnect the companion app first if you are using it over USB — +> the raw GPX stream will otherwise look like garbage frames to the app. +> If the app is connected over BLE the export is safe; USB receive is +> ignored while BLE is connected. +""" + +import argparse +import os +import sys +import time +from datetime import datetime + +import serial +import serial.tools.list_ports + +GPX_START = b"" +DEFAULT_TIMEOUT_S = 30 + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Capture a GPX trail dump from the Wio Tracker L1 over USB serial" + ) + parser.add_argument("--port", "-p", help="Serial port (default: auto-detect)") + parser.add_argument( + "--out", "-o", + help="Output file path (default: tools/gpx/trail_.gpx)", + ) + parser.add_argument( + "--timeout", "-t", type=int, default=DEFAULT_TIMEOUT_S, + help=f"Seconds to wait for the GPX header before giving up (default: {DEFAULT_TIMEOUT_S})", + ) + return parser.parse_args() + + +def find_wio_port(): + """Look for a Wio/Seeed CDC interface; fall back to a generic ACM/COM port.""" + ports = serial.tools.list_ports.comports() + for port in ports: + desc = str(port.description).lower() + if "wio" in desc or "seeed" in desc: + return port.device + for port in ports: + if "ACM" in port.device or port.device.startswith("COM"): + return port.device + return None + + +def default_out_path(): + os.makedirs("tools/gpx", exist_ok=True) + stamp = datetime.now().strftime("trail_%Y-%m-%d_%H-%M-%S.gpx") + return os.path.join("tools/gpx", stamp) + + +def wait_for_header(ser, timeout_s): + """Drain bytes until we see the GPX start marker. Returns the buffered + chunk that *includes* `= 0: + return bytes(window[idx:]) + # Keep only the last len(GPX_START) - 1 bytes — anything earlier + # cannot be the start of a marker we haven't matched yet. + if len(window) > len(GPX_START): + window = bytearray(window[-(len(GPX_START)):]) + else: + # short idle — keep waiting + time.sleep(0.01) + return None + + +def stream_until_footer(ser, initial, out_file): + """Write bytes to out_file until we see ``. Returns total written.""" + total = 0 + out_file.write(initial) + total += len(initial) + print(f"Receiving... ({total} B)", end="", flush=True) + + # Sliding tail keeps us from missing a marker split across reads. + tail_keep = len(GPX_END) + tail = bytearray(initial[-tail_keep:]) + if GPX_END in tail: + idx = bytes(tail).find(GPX_END) + # already complete (unlikely on first chunk, but be safe) + return total + + last_report = total + while True: + chunk = ser.read(256) + if not chunk: + # Device went quiet — bail rather than hang forever + if total > last_report: + print(f"\rReceived {total} B (device idle) ") + else: + print("\nDevice went quiet before — partial file kept.") + return total + out_file.write(chunk) + total += len(chunk) + tail += chunk + if len(tail) > tail_keep * 4: + tail = bytearray(tail[-tail_keep * 4:]) + if GPX_END in tail: + return total + if total - last_report >= 1024: + print(f"\rReceiving... ({total} B)", end="", flush=True) + last_report = total + + +def main(): + args = parse_args() + + port = args.port or find_wio_port() + if not port: + print("Error: no serial port found.") + print("\nAvailable ports:") + for p in serial.tools.list_ports.comports(): + print(f" {p.device}: {p.description}") + port = input("\nEnter serial port manually: ").strip() + if not port: + sys.exit(1) + + out_path = args.out or default_out_path() + + print(f"Connecting to {port} at 115200 baud...") + try: + ser = serial.Serial(port, 115200, timeout=0.2) + except serial.SerialException as e: + print(f"Error opening serial port: {e}") + sys.exit(1) + + print("Connected.") + print() + print("On the device: Tools › Trail › Hold Enter ›") + print(" Export (live) — current RAM trail") + print(" Export (saved) — /trail file from flash") + print() + print(f"Waiting for GPX stream (up to {args.timeout}s)...") + + initial = wait_for_header(ser, args.timeout) + if initial is None: + print(f"Timed out after {args.timeout}s — no