Video Tutorial (Optional)
Watch first if you want to see Heltec V3 LoRa boards sending GPS over Meshtastic and the receiver-side Python listener in action.
Project Overview
Heltec V3 LoRa + GT-U7 NEO-6M GPS: Build a simple remote GPS tracking setup where one Heltec node reads live coordinates from a GT-U7 NEO-6M module and sends the position over Meshtastic to a second Heltec base station.
This project focuses on remote tracking, not just printing coordinates locally. The GPS module calculates position, the tracker Heltec publishes it over the LoRa mesh, and the base station receives it for display on a computer over USB.
- Time: 30 to 60 minutes
- Skill level: Beginner to Intermediate
- What you will build: A simple long-range GPS tracking setup with one Meshtastic GPS sender and one Meshtastic receiver
The system has two nodes:
- Tracker node: Heltec board + GT-U7 NEO-6M GPS module
- Base station: second Heltec board running Meshtastic on the same channel
Data flow:
Satellites
↓
GT-U7 NEO-6M GPS Module
↓ UART
Heltec Tracker Node
↓ Meshtastic / LoRa Mesh
Heltec Base Station
↓
Computer Terminal / Logging Script
Parts List
From ShillehTek
- GT-U7 NEO-6M GPS module - GPS receiver used on the tracker node
- 120pcs 10cm jumper wire set - for UART and power connections
- 830 point solderless breadboard - quick, no-solder prototyping
- ShillehTek store - sensors, breadboards, jumper wires, and embedded hardware
External
- 2x Heltec LoRa ESP32 boards (ShillehTek does not currently sell Heltec boards)
- 2x USB data cables for the Heltec boards
- 2x LoRa antennas, one for each Heltec board
- Computer with Meshtastic installed or a Python environment available
Important: attach the LoRa antennas before testing radio communication. GPS reception is also much better outdoors or near a window.
Step-by-Step Guide
Step 1 - Wire the tracker node
Goal: Build the Meshtastic node that reads GPS data.
What to do: Only the tracker node needs the GPS module attached. Wire the GT-U7 NEO-6M to the first Heltec V3 using the tested working mapping below:
- GT-U7 VCC → Heltec 5V
- GT-U7 GND → Heltec GND
- GT-U7 TX → Heltec GPIO19
- GT-U7 RX → Heltec GPIO20
Attach the LoRa antenna and connect the board to your computer with a USB data cable.
Expected result: Your tracker Heltec is powered, wired to the GPS module, and ready for Meshtastic GPS configuration.
Step 2 - Set up the base station
Goal: Prepare the receiving node.
What to do: No sensor wiring is required on the base station. Connect the second Heltec board to USB power, attach its LoRa antenna, and keep it on the same Meshtastic channel as the tracker node.
Expected result: Your base station Heltec is powered and ready to receive GPS position packets over the mesh.
Step 3 - Configure Meshtastic GPS on the tracker
Goal: Tell Meshtastic where the GPS is connected.
What to do: This project uses Meshtastic firmware on both Heltec boards. You do not need to write custom sender firmware just to broadcast GPS coordinates. In Meshtastic, configure the tracker node with:
GPS Mode = ENABLED
GPS RX Pin = 19
GPS TX Pin = 20
Expected result: Once the wiring and power are correct, the tracker node starts reporting position data to the mesh.
Step 4 - Wait for GPS lock
Goal: Get valid coordinates from the GT-U7 module.
What to do: Move the tracker node outdoors or near a window and give the module time to acquire satellites.
Expected result: The tracker node begins reporting live coordinates such as latitude, longitude, and altitude.
Step 5 - Connect the receiver node to a computer
Goal: Display the received coordinates somewhere easy to read.
What to do: Plug the base station Heltec into your computer over USB. Save the script below as receiver_display.py on your computer. It listens to the receiver node over serial (USB) and prints GPS position packets that arrive from the mesh.
Install the Meshtastic Python library (use a virtual environment if you prefer), then run the script:
pip install meshtastic
python3 receiver_display.py --port /dev/cu.usbserial-RECEIVER --sender-id !0408a510
receiver_display.py (receiver node listener):
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
import meshtastic.serial_interface
from pubsub import pub
def pick_first(mapping: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in mapping and mapping[key] not in (None, ""):
return mapping[key]
return None
def format_value(value: Any, digits: int = 6) -> str:
if value is None:
return "-"
if isinstance(value, float):
return f"{value:.{digits}f}"
return str(value)
def format_timestamp(epoch: Any) -> str:
if not epoch:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
return datetime.fromtimestamp(float(epoch)).strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OSError):
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def normalize_coordinate(value: Any) -> Any:
if value is None:
return None
try:
numeric = float(value)
except (TypeError, ValueError):
return value
if abs(numeric) > 180:
return numeric * 1e-7
return numeric
class PositionPrinter:
def __init__(self, sender_id: str | None = None, csv_path: Path | None = None) -> None:
self.sender_id = sender_id.lower() if sender_id else None
self.csv_path = csv_path
if self.csv_path:
self.csv_path.parent.mkdir(parents=True, exist_ok=True)
file_exists = self.csv_path.exists()
self.csv_file = self.csv_path.open("a", newline="", encoding="utf-8")
self.csv_writer = csv.writer(self.csv_file)
if not file_exists:
self.csv_writer.writerow(
[
"received_at",
"from_id",
"node_name",
"latitude",
"longitude",
"altitude_m",
"sats_in_view",
"speed_m_s",
]
)
self.csv_file.flush()
else:
self.csv_file = None
self.csv_writer = None
def close(self) -> None:
if self.csv_file:
self.csv_file.close()
def _node_name(self, packet: dict[str, Any], interface: Any) -> str:
from_num = packet.get("from")
node = interface.nodesByNum.get(from_num, {}) if from_num is not None else {}
user = node.get("user", {})
return (
pick_first(user, "longName", "shortName")
or packet.get("fromId")
or f"node-{from_num}"
)
def _should_print(self, packet: dict[str, Any]) -> bool:
if not self.sender_id:
return True
from_id = str(packet.get("fromId", "")).lower()
return from_id == self.sender_id
def on_position(self, packet: dict[str, Any], interface: Any) -> None:
if not self._should_print(packet):
return
decoded = packet.get("decoded", {})
position = decoded.get("position", {}).copy()
from_num = packet.get("from")
if from_num is not None:
cached_position = interface.nodesByNum.get(from_num, {}).get("position", {})
if isinstance(cached_position, dict):
position = {**cached_position, **position}
latitude = normalize_coordinate(pick_first(position, "latitude", "latitudeI"))
longitude = normalize_coordinate(pick_first(position, "longitude", "longitudeI"))
altitude = pick_first(position, "altitude", "altitudeMsl")
sats = pick_first(position, "satsInView", "sats_in_view")
speed = pick_first(position, "groundSpeed", "ground_speed")
timestamp = pick_first(position, "timestamp", "time", "lastHeard")
node_name = self._node_name(packet, interface)
if packet.get("fromId"):
from_id = packet["fromId"]
elif from_num is not None:
from_id = f"!{from_num:x}"
else:
from_id = "unknown"
when = format_timestamp(timestamp)
print(
f"[{when}] {node_name} ({from_id}) "
f"lat={format_value(latitude)} lon={format_value(longitude)} "
f"alt={format_value(altitude, 1)}m sats={format_value(sats, 0)} "
f"speed={format_value(speed, 2)}m/s"
)
sys.stdout.flush()
if self.csv_writer:
self.csv_writer.writerow(
[
when,
from_id,
node_name,
latitude,
longitude,
altitude,
sats,
speed,
]
)
self.csv_file.flush()
def on_connection(interface: Any, topic: Any = pub.AUTO_TOPIC) -> None:
local_info = interface.localNode
local_user = local_info.get("user", {}) if isinstance(local_info, dict) else {}
local_name = pick_first(local_user, "longName", "shortName") or "unknown"
print(f"Connected to receiver node: {local_name}")
print("Waiting for Meshtastic GPS position packets...")
sys.stdout.flush()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Listen for Meshtastic position packets and print coordinates."
)
parser.add_argument(
"--port",
help="Receiver node serial port, for example /dev/cu.usbserial-0001",
)
parser.add_argument(
"--sender-id",
help="Optional sender node ID to filter, for example !abcd1234",
)
parser.add_argument(
"--csv",
type=Path,
help="Optional CSV log path, for example positions.csv",
)
return parser
def main() -> int:
args = build_parser().parse_args()
printer = PositionPrinter(sender_id=args.sender_id, csv_path=args.csv)
pub.subscribe(on_connection, "meshtastic.connection.established")
pub.subscribe(printer.on_position, "meshtastic.receive.position")
interface = None
try:
if args.port:
interface = meshtastic.serial_interface.SerialInterface(devPath=args.port)
else:
interface = meshtastic.serial_interface.SerialInterface()
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nStopping listener.")
return 0
finally:
printer.close()
if interface is not None:
interface.close()
if __name__ == "__main__":
raise SystemExit(main())
Replace /dev/cu.usbserial-RECEIVER with your base station’s serial port and !0408a510 with your tracker node’s Meshtastic ID if different. Omit --sender-id to print positions from every node. Optional: add --csv positions.csv to append received fixes to a CSV file.
Expected result: When the tracker broadcasts a fresh position, the computer shows a line containing the sender ID and coordinates.
Step 6 - Verify live tracking
Goal: Confirm that the full remote tracking chain is working.
What to do: Watch the receiver output while moving the GPS node or waiting for a new position update. You can also request the sender's current position directly from the receiver node:
meshtastic --port /dev/cu.usbserial-RECEIVER --dest !0408a510 --request-position
In testing, the receiver returned coordinates like this:
Position received: (41.7595392, -87.8968832) 212m precision:13
[2026-04-05 19:56:50] Meshtastic a510 (!0408a510) lat=41.759539 lon=-87.896883 alt=205m sats=3 speed=101m/s
Example receiver output while waiting for packets:
Connected to receiver node: Meshtastic 6c94
Waiting for Meshtastic GPS position packets...
[2026-04-05 19:56:50] Meshtastic a510 (!0408a510) lat=41.759539 lon=-87.896883 alt=205m sats=3 speed=101m/s
Expected result: The receiver node gets live GPS packets from the tracker node over the Meshtastic network.
Conclusion
You built a working proof-of-concept GPS tracker using two Heltec LoRa boards and a GT-U7 NEO-6M module. The tracker node reads GPS over UART, Meshtastic broadcasts the position over LoRa mesh, and the base station receives the coordinates for display over USB.
Want the exact parts used in this build? Grab the GT-U7 NEO-6M GPS module, jumper wires, and an 830 point breadboard from ShillehTek.com. If you want help customizing this project or building something for your product, check out our IoT consulting services or hire us on UpWork.


