Building a GPS Motorsports Performance Timer, Part 1
Lately, I’ve returned to hands-on projects by exploring new programming languages and experimenting with hardware, always chasing something with a learning curve. Seeking to fill my evenings, I decided to combine my interests in a hardware project using my Raspberry Pi and an Adafruit GPS module.
My initial goal was straightforward: build a device to measure 0–60 mph times using GPS data. As I iterated, the project grew. What started as a simple timer became a more capable system, transforming a Raspberry Pi and a $30 GPS breakout board into a flexible tool for drag timing and autocross analysis.
Environment: Raspberry Pi 5, Raspberry Pi OS, Adafruit Ultimate GPS (USB, MTK3339 chipset), Python 3.14, pyserial + pynmea2.
The hardware and the protocol
The Adafruit Ultimate GPS sends data over USB serial at 9600 baud. It uses the NMEA 0183 protocol, which is an ASCII-based standard for GPS data. Each data sentence is a single line that starts with a $ sign, followed by comma-separated values, and ends with a * and a two-digit checksum, terminated by a carriage return and newline (\r\n).
$GPRMC,194530.000,A,3309.1234,N,08312.5678,W,0.27,68.40,180626,,,A*6FThe most important detail is that the GPS itself calculates speed in real time. In the example, the value 0.27 represents speed over ground, which the GPS computes internally using the Doppler shift from the satellites. This means I do not have to estimate speed from changes in position; I just read this value and convert it to the units I want. This approach significantly affects the project’s design choice, as I will explain later.
One aside worth knowing up front: the GP in $GPRMC is the talker ID. A GPS-only fix is GP; a multi-constellation fix (GPS + GLONASS, say) comes through as GN — $GNRMC. Your parsing has to treat both the same, and as you’ll see, the library makes that easy.
Step 1: Reading GPS Data
Before parsing anything, we need to prove that data is physically flowing from the GPS module. The first program I wrote does nothing but open the port and print raw lines:
import serial with serial.Serial(”/dev/ttyUSB0”, 9600, timeout=1) as ser:
while True:
line = ser.readline().decode(”ascii”, errors=”replace”).strip()
if line:
print(line)Setting errors=”replace” is not just defensive coding. When the port first opens or after any baud rate adjustment, the initial bytes are often incomplete - truncated sentences or corrupted bits are common. If you decode strictly, your loop will crash on the first malformed byte, leading you to suspect a hardware issue when in reality you’ve simply intercepted the stream mid-sentence.
This simple viewer quickly became my go-to debug mode, revealing details most tutorials ignore. I saw that the many sentence types—RMC, GGA, GSA, GSV, VTG—were most irrelevant to my use case. It also showed that data starts flowing before a valid fix is obtained, with empty fields as the receiver seeks satellites. Most importantly, it revealed the real-world time-to-first-fix from a cold start, which can take several minutes, even with a clear sky.
To make sense of the overwhelming stream of data, I color-coded each line by sentence type:
import pynmea2
COLORS = {”RMC”: “\033[92m”, “GGA”: “\033[94m”, “GSA”: “\033[93m”,
“GSV”: “\033[90m”, “VTG”: “\033[96m”}
RESET = “\033[0m”
def label(line: str) -> str:
try:
stype = pynmea2.parse(line).sentence_type
except pynmea2.ParseError:
return “\033[91mRAW \033[0m”
return f”{COLORS.get(stype, ‘’)}{stype:<4}{RESET}”
# in the loop: print(label(line), line)With this approach, issues like a dead feed or missing RMC sentences become immediately obvious, rather than getting lost in the scrollback.
Step 2: Find the port without hardcoding it
/dev/ttyUSB0 works until it doesn’t. Add a second serial device, switch to a Mac (where it’s /dev/cu.usbserial-XXXX), or use Windows (COM3), and the device path changes. I didn’t want users to have to guess, so I built a way to automatically find the module. The Adafruit board shows up as a CP2104 USB-to-UART bridge, thanks to the Silicon Labs chip. I scan available serial ports and match to find it.
import serial.tools.list_ports
def find_gps_port():
for p in serial.tools.list_ports.comports():
desc = (p.description or “”).lower()
mfr = (p.manufacturer or “”).lower()
if any(k in desc or k in mfr for k in (”cp210”, “cp2104”, “adafruit”, “gps”, “uart”)):
return p.device
for p in serial.tools.list_ports.comports(): # fallback
if “usb” in (p.device or “”).lower():
return p.device
return NoneThis approach is a heuristic, not foolproof—a convenience that may need revisiting. When I ported the project to .NET, I found that System.IO.Ports doesn’t expose device descriptions or manufacturer strings, so the method wasn’t portable. That limitation shaped the project’s next phase.
A common stumbling block on Linux is needing to add your user to the dialout group (sudo usermod --aG dialout $USER). This change only takes effect after logging out and back in. For example, retrying in the same shell results in the same permission error, wasting time troubleshooting what is actually a session issue. Remember to always log out and back in after updating group memberships.
Step 3: Configuring the module
The default output is 1 Hz, which is useless for launch timing. One sample per second brings up to a full second of error for your 0–60. I need 5–10 Hz. To set that, you write a PMTK command—the MTK3339’s configuration dialect—back to the module as a framed NMEA sentence:
PMTK_SET_UPDATE_10HZ = b”$PMTK220,100*2F\r\n” # 100 ms = 10 HzPMTK_SET_UPDATE_1HZ = b”$PMTK220,1000*1F\r\n” # 1000 ms = 1 Hz
That *2F is a checksum, and it is not optional. Send a PMTK command with the wrong checksum, and the module silently ignores it — no error, no acknowledgment; it just keeps running at 1 Hz while you wonder why your “10 Hz” stream looks slow. The checksum is the XOR of every byte between $ and *:
def nmea_checksum(payload: str) -> str:
c = 0
for ch in payload:
c ^= ord(ch)
return f”{c:02X}”
# nmea_checksum(”PMTK220,100”) -> “2F”At 9600 baud, you can transfer roughly 960 bytes per second. When the GPS sends several NMEA sentences each cycle, such as RMC, GGA, and multiple GSV sentences, the total can easily exceed the available bandwidth if the update rate is set too high (e.g., 10 Hz). This will cause sentences to be dropped, potentially resulting in data loss. To prevent this, you must either reduce the number of sentences sent (using a command like PMTK314 to select only necessary sentences such as RMC) or increase the baud rate (using PMTK251). Currently, my prototype increases the update rate but does not limit the number of sentences, leading to greater data loss. I plan to address this in a later rewrite for improved reliability.
One important detail: I reset the module to 1 Hz in close(), making sure the hardware is always left in a known state. Leaving devices in an unexpected configuration after a crash is a recipe for intermittent bugs that are hard to track down.
Steps 4 & 5: parsing NMEA and deriving speed
One advantage of Python is the availability of specialized libraries. Custom parsing is unnecessary since pynmea2 manages checksum validation, field typing, and a variety of sentence types; implementing these features from scratch would not be an efficient use of time for this project. The following tips may be helpful during this step:
Skip anything that doesn’t start with $.
Parse inside a try/except pynmea2.ParseError, because the stream will hand you junk.
Keep only RMC, the “recommended minimum” sentence, which carries position, speed-over-ground, course, and time in a single line. Checking sentence_type == “RMC” matches both GPRMC and GNRMC, so multi-constellation just works.
Require an active fix: status == “A”. A status of “V” means void; the receiver is talking, but doesn’t actually know where it is.
This brings us to a key design decision: there are two primary ways to extract speed from GPS data, and the choice between them fundamentally shapes the system’s accuracy and responsiveness.
You can calculate speed from GPS data in two main ways, each with important trade-offs. First, you might compute speed by finding the distance between two positions (latitude and longitude) using the haversine formula, then dividing by the time difference. However, this amplifies errors from GPS position noise, leading to inaccurate speed estimates, especially at lower velocities. The second method is to use the spd_over_grnd field provided by the GPS, which is calculated on the device from the Doppler shift in the satellite signals. This value is less affected by positional errors, yielding smoother, more accurate speed readings with minimal delay. I use the Doppler-derived speed to measure velocity and, with the position data, the haversine formula to calculate distance traveled.
Another note: the receiver will return spd_over_grnd in knots, not mph. So, we will need to convert that.
speed_mph = float(msg.spd_over_grnd or 0) * 1.15078Including or 0 is important because the field is blank when stationary, and true_course is also empty in that case, heading is genuinely unknown when the receiver isn’t moving. That’s why I treat heading as optional, rather than defaulting to an artificial 0.0 north.
Design Decisions
Everything above is messy I/O. The trick to keeping it from metastasizing throughout the codebase is to quarantine it behind a single clean type. Downstream, the drag calculator, the autocross logic will depend on the newly created GPSPoint, never on serial or NMEA:
import time
from dataclasses import dataclass
from typing import Optional, Iterator
import serial
import serial.tools.list_ports
import pynmea2
KNOTS_TO_MPH = 1.15078
PMTK_SET_UPDATE_10HZ = b"$PMTK220,100*2F\r\n"
PMTK_SET_UPDATE_1HZ = b"$PMTK220,1000*1F\r\n"
@dataclass
class GPSPoint:
timestamp: float # host clock, time.time()
speed_mph: float
latitude: float
longitude: float
gps_time: str
heading_deg: Optional[float] = None
def find_gps_port() -> Optional[str]:
for p in serial.tools.list_ports.comports():
blob = ((p.description or "") + (p.manufacturer or "")).lower()
if any(k in blob for k in ("cp210", "cp2104", "adafruit", "gps", "uart")):
return p.device
for p in serial.tools.list_ports.comports():
if "usb" in (p.device or "").lower():
return p.device
return None
class GPSReader:
def __init__(self, port: str, baudrate: int = 9600, hz: int = 10):
self.port, self.baudrate, self.hz = port, baudrate, hz
self._serial = None
def open(self):
self._serial = serial.Serial(self.port, self.baudrate, timeout=1)
time.sleep(0.5) # let the CP2104 settle
self._serial.write(PMTK_SET_UPDATE_10HZ if self.hz >= 10 else PMTK_SET_UPDATE_1HZ)
time.sleep(0.1)
def close(self):
if self._serial and self._serial.is_open:
self._serial.write(PMTK_SET_UPDATE_1HZ) # leave it clean
self._serial.close()
def __enter__(self):
self.open()
return self
def __exit__(self, *_):
self.close()
def read_raw_lines(self) -> Iterator[str]:
while True:
try:
line = self._serial.readline().decode("ascii", errors="replace").strip()
except serial.SerialException:
break
if line:
yield line
def read_points(self) -> Iterator[GPSPoint]:
for line in self.read_raw_lines():
if not line.startswith("$"):
continue
try:
msg = pynmea2.parse(line)
except pynmea2.ParseError:
continue
if getattr(msg, "sentence_type", None) != "RMC":
continue
if getattr(msg, "status", None) != "A": # A = active, V = void
continue
yield GPSPoint(
timestamp=time.time(),
speed_mph=float(msg.spd_over_grnd or 0) * KNOTS_TO_MPH,
latitude=msg.latitude,
longitude=msg.longitude,
gps_time=str(msg.timestamp),
heading_deg=float(msg.true_course) if msg.true_course else None,
)
if __name__ == "__main__":
port = find_gps_port()
if not port:
raise SystemExit("No GPS found. Is it plugged in? Are you in the dialout group?")
with GPSReader(port, hz=10) as gps:
for point in gps.read_points():
print(f"{point.speed_mph:5.1f} mph ({point.latitude:.5f}, {point.longitude:.5f})")There are two subtle but important design choices here. First, read_points() is implemented as a generator, which means you can consume it directly in a for loop, and it’s trivial to swap in a list of captured sentences for testing. Second, using a with block ensures the serial port is always closed and the update rate reset, even if an exception occurs. The 0.5-second pause after opening the port and the 0.1-second delay after sending a configuration command are not arbitrary; they reflect the real timing requirements of communicating with the hardware.
Developing this without a moving car
Nearly all of this development can be done at your desk. With the module stationary, you’ll see speed readings near zero with a valid fix, ideal for testing fix-status filtering and observing how much the reported speed drifts when not moving. Taking the module for a walk in a parking lot under open sky provides real-world speed and heading data.
Why Python was the right call
Python enabled rapid iteration and provided a REPL that could interact directly with live hardware. Libraries like pyserial and pynmea2 took care of the tedious, error-prone details, freeing me to focus on the core challenges. By the end of the process, I had a solid grasp of the domain: the RMC-only data pipeline, the tradeoffs between update rate and baud rate, fix-status filtering, the choice of Doppler-derived speed over position-derived speed, and the importance of a clean GPSPoint boundary to contain I/O complexity. This understanding made the eventual rewrite simple and effective.
Next post: re-implementing all of this in .NET, and I will go into detail on why I chose .NET and not another language/platform.
