Integrating live geospatial data into a web dashboard is a rite of passage for embedded developers. When building a vehicle tracker, asset monitor, or mobile weather station, the combination of a Raspberry Pi, a hardware GPS module, and the Google Maps JavaScript API provides a robust, customizable foundation. This guide walks through building a headless GPS tracker that reads NMEA sentences over UART, parses them in Python, and serves a live-updating map via a local Flask server.
Project Architecture & Target Board
This build targets the Raspberry Pi 4 Model B (4GB variant). While the Pi Zero 2 W is highly tempting for mobile deployments due to its lower power draw, the Pi 4B's extra RAM and USB-C power delivery make it significantly more forgiving when running a local web server, parsing serial streams, and rendering browser-based map tiles simultaneously. If you are strictly battery-constrained, see the Extending and Simplifying section for Zero 2 W optimization.
The architecture relies on a hardware UART connection rather than USB. USB-to-Serial adapters introduce unnecessary latency and bulk. By wiring the GPS module directly to the Pi's GPIO header, we minimize points of failure and reduce the physical footprint.
Hardware BOM & UART Pin Mapping
Sourcing the right GPS module is critical. Cheap NEO-6M clones often lack adequate ceramic antennas and take 10+ minutes to get a cold fix. We are using the Adafruit Ultimate GPS, which features a PA1010D chipset and an integrated Low Noise Amplifier (LNA).
| Component | Exact Model / Variant | Approx. Cost |
|---|---|---|
| Microcontroller | Raspberry Pi 4 Model B (4GB RAM) | $55.00 |
| GPS Module | Adafruit Ultimate GPS Breakout (PA1010D, Product ID: 746) | $29.95 |
| Storage | SanDisk 32GB microSD (Class 10, A1 rated) | $8.00 |
| Wiring | Silicone jumper wires (Female-to-Female, 20cm) | $4.00 |
GPIO Pin Mapping Table
The Raspberry Pi 4 routes its primary hardware UART (ttyAMA0) to GPIO pins 14 and 15. Cross the TX and RX lines—transmit always goes to receive.
| Pi 4B Pin (Physical) | BCM GPIO | Function | Adafruit GPS Pin |
|---|---|---|---|
| Pin 8 | GPIO 14 (TXD) | UART Transmit | RX |
| Pin 10 | GPIO 15 (RXD) | UART Receive | TX |
| Pin 1 | 3.3V Power | VCC | VIN |
| Pin 6 | Ground | GND | GND |
Software Setup & Console Disable
By default, the Raspberry Pi routes the Linux serial console to the hardware UART. If you do not disable this, the OS will spam boot logs into your GPS module's RX pin, and your Python script will read garbage data. Furthermore, the Pi 4's Bluetooth module shares the hardware UART by default. We must disable Bluetooth to reclaim the high-speed ttyAMA0 port for the GPS.
- Flash Raspberry Pi OS Lite (64-bit) to your SD card using the official imager. Boot the Pi and connect via SSH.
- Edit the boot configuration to disable Bluetooth and the serial console:
sudo nano /boot/firmware/config.txt(or/boot/config.txton older OS versions). - Add this line to the very bottom of the file:
dtoverlay=disable-bt - Disable the serial console via raspi-config:
sudo raspi-config→ Interface Options → Serial Port → No to login shell, Yes to serial hardware. - Reboot the Pi:
sudo reboot. - Install the required Python libraries:
pip3 install pyserial pynmea2 flask.
ls -l /dev/serial0. It should point to ttyAMA0, not ttyS0. The ttyS0 mini-UART is clocked to the CPU frequency and will cause baud-rate drift, resulting in corrupted NMEA sentences.
Complete Python Tracker Code
The following Python script spins up a background thread to read the serial port, parses the NMEA GGA sentences (which contain latitude, longitude, and fix quality), and serves a Flask web page. The frontend uses the Google Maps JavaScript API to plot the marker and pan the map.
Note: You must generate a Google Maps API key in the Google Cloud Console, enable the Maps JavaScript API, and replace YOUR_API_KEY in the HTML template string below.
import serial
import pynmea2
import threading
import time
from flask import Flask, jsonify, render_template_string
app = Flask(__name__)
# Shared state for GPS coordinates
latest_data = {'lat': 37.7749, 'lon': -122.4194, 'status': 'Waiting for GPS fix...'}
lock = threading.Lock()
def read_gps():
# Pin mapping: Pi RXD (GPIO 15 / /dev/serial0) wired to GPS TX
port = '/dev/serial0'
try:
# 9600 baud is standard for PA1010D and NEO-6M modules
ser = serial.Serial(port, baudrate=9600, timeout=1)
except serial.serialutil.SerialException as e:
print(f"Fatal Serial Error: {e}")
return
while True:
try:
line = ser.readline().decode('ascii', errors='replace')
# GGA sentences contain the essential fix data
if line.startswith('$GPGGA') or line.startswith('$GNGGA'):
msg = pynmea2.parse(line)
if msg.is_valid:
with lock:
latest_data['lat'] = msg.latitude
latest_data['lon'] = msg.longitude
latest_data['status'] = f"Fix Acquired | Sats: {msg.num_sats}"
except pynmea2.ParseError:
# Ignore malformed checksum lines
continue
except Exception as e:
print(f"Read error: {e}")
time.sleep(1)
HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<title>Pi GPS Tracker</title>
<style> #map { height: 100vh; width: 100%; } body { margin: 0; } </style>
</head>
<body>
<div id='map'></div>
<script>
let map, marker;
function initMap() {
map = new google.maps.Map(document.getElementById('map'), {
zoom: 16, center: { lat: 37.7749, lng: -122.4194 }
});
marker = new google.maps.Marker({ position: { lat: 37.7749, lng: -122.4194 }, map: map });
setInterval(updatePosition, 2000);
}
function updatePosition() {
fetch('/api/gps').then(r => r.json()).then(data => {
let pos = { lat: data.lat, lng: data.lon };
marker.setPosition(pos);
map.panTo(pos);
document.title = data.status;
});
}
</script>
<script async defer src='https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&callback=initMap'></script>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/api/gps')
def api_gps():
with lock:
return jsonify(latest_data)
if __name__ == '__main__':
print("Starting GPS background thread...")
threading.Thread(target=read_gps, daemon=True).start()
print("Starting Flask server on port 5000...")
app.run(host='0.0.0.0', port=5000)
Debugging: When the UART Fails
UART configuration on the Pi is notoriously fragile. If your script crashes immediately upon execution, you will likely encounter this exact error string:
serial.serialutil.SerialException: [Errno 13] could not open port /dev/ttyAMA0: [Errno 13] Permission denied: '/dev/ttyAMA0'
OR
serial.serialutil.SerialException: [Errno 2] could not open port /dev/serial0: [Errno 2] No such file or directory: '/dev/serial0'
The First Three Things to Check
- Verify the Symlink and Device Tree: Run
ls -l /dev/serial0. If it returns "No such file", thedtoverlay=disable-btline inconfig.txtfailed to load, or you are using an older OS where the path is simply/boot/config.txtinstead of/boot/firmware/config.txt. - Check User Permissions: The
[Errno 13] Permission deniederror means your user lacks access to the dialout group. Fix this permanently by runningsudo usermod -a -G dialout $USER, then log out and log back in. - Confirm Console is Disabled: Run
cat /proc/cmdline. If you seeconsole=serial0,115200anywhere in that string, the OS is still hogging the port. Re-runsudo raspi-configand explicitly disable the serial login shell.
Extending and Simplifying the Build
How to Simplify: If fighting with Linux UART mappings is causing too much friction, switch to an I2C GPS module like the Adafruit Ultimate GPS I2C variant. I2C operates on a shared bus (GPIO 2 and 3) and does not require disabling Bluetooth or altering boot parameters. You will trade a tiny amount of CPU overhead for a massive gain in setup reliability.
How to Extend: For a true mobile asset tracker, the local Flask server is insufficient because you cannot access it once the Pi leaves your home WiFi. Extend this build by adding a Sixfab Raspberry Pi 4G/LTE Mini HAT. Replace the Flask API endpoint with an MQTT publisher (using the paho-mqtt library) that pushes the latest_data JSON payload to a cloud broker like HiveMQ or AWS IoT Core. Your frontend Google Maps dashboard can then be hosted on any static web host, subscribing to the MQTT topic via WebSockets.
Frequently Asked Questions
Can I use Raspberry Pi Google Maps offline without an API key?
No. The Google Maps JavaScript API strictly requires an active internet connection to fetch map tiles and a valid, billing-enabled API key to authenticate requests. If your tracker will operate in areas without cellular or WiFi coverage, you must pivot to an offline mapping library. Use Leaflet.js paired with pre-downloaded OpenStreetMap (OSM) raster tiles stored locally on the Pi's SD card. You will lose real-time traffic data, but the map rendering will function entirely offline.
How do I center the Raspberry Pi Google Maps dashboard on my current location?
In the provided JavaScript code, the updatePosition() function fetches the latest coordinates every 2 seconds. The line map.panTo(pos); automatically re-centers the map viewport on the new marker position. If you find the constant panning disorienting while reviewing historical routes, you can remove map.panTo(pos); and rely solely on marker.setPosition(pos);, which moves the pin without forcing the map camera to follow it.
Why is my Raspberry Pi Google Maps tracker draining the battery so fast?
The Raspberry Pi 4 Model B draws between 3W and 6W under load, which will drain a standard 10,000mAh USB power bank in roughly 6 to 8 hours. To extend deployment time, migrate the code to a Raspberry Pi Zero 2 W, which idles around 1.2W. Furthermore, disable the HDMI output circuitry via the command line by running tvservice -o, and disable the onboard LEDs by adding dtparam=act_led_trigger=none and dtparam=pwr_led_trigger=none to your config.txt. These tweaks can push a Zero 2 W tracker past 18 hours on the same power bank.






