Difficulty: Intermediate | Time: 2 Hours | Cost: ~$95

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).

Hardware Bill of Materials
ComponentExact Model / VariantApprox. Cost
MicrocontrollerRaspberry Pi 4 Model B (4GB RAM)$55.00
GPS ModuleAdafruit Ultimate GPS Breakout (PA1010D, Product ID: 746)$29.95
StorageSanDisk 32GB microSD (Class 10, A1 rated)$8.00
WiringSilicone 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 GPIOFunctionAdafruit GPS Pin
Pin 8GPIO 14 (TXD)UART TransmitRX
Pin 10GPIO 15 (RXD)UART ReceiveTX
Pin 13.3V PowerVCCVIN
Pin 6GroundGNDGND

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.

  1. Flash Raspberry Pi OS Lite (64-bit) to your SD card using the official imager. Boot the Pi and connect via SSH.
  2. Edit the boot configuration to disable Bluetooth and the serial console: sudo nano /boot/firmware/config.txt (or /boot/config.txt on older OS versions).
  3. Add this line to the very bottom of the file: dtoverlay=disable-bt
  4. Disable the serial console via raspi-config: sudo raspi-config → Interface Options → Serial Port → No to login shell, Yes to serial hardware.
  5. Reboot the Pi: sudo reboot.
  6. Install the required Python libraries: pip3 install pyserial pynmea2 flask.
Pro-Tip: After rebooting, verify your UART symlink is correct by running 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

  1. Verify the Symlink and Device Tree: Run ls -l /dev/serial0. If it returns "No such file", the dtoverlay=disable-bt line in config.txt failed to load, or you are using an older OS where the path is simply /boot/config.txt instead of /boot/firmware/config.txt.
  2. Check User Permissions: The [Errno 13] Permission denied error means your user lacks access to the dialout group. Fix this permanently by running sudo usermod -a -G dialout $USER, then log out and log back in.
  3. Confirm Console is Disabled: Run cat /proc/cmdline. If you see console=serial0,115200 anywhere in that string, the OS is still hogging the port. Re-run sudo raspi-config and 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.