<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Mick's Dev Corner]]></title><description><![CDATA[My personal developer journey and other things I find interesting in tech]]></description><link>https://www.micksdevcorner.com</link><image><url>https://substackcdn.com/image/fetch/$s_!oMS2!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb2db9840-f714-463e-8b9e-f26dc8603a2f_465x465.jpeg</url><title>Mick&apos;s Dev Corner</title><link>https://www.micksdevcorner.com</link></image><generator>Substack</generator><lastBuildDate>Mon, 03 Aug 2026 20:55:00 GMT</lastBuildDate><atom:link href="https://www.micksdevcorner.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Mickey M.]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[micksdevcorner@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[micksdevcorner@substack.com]]></itunes:email><itunes:name><![CDATA[Mickey M.]]></itunes:name></itunes:owner><itunes:author><![CDATA[Mickey M.]]></itunes:author><googleplay:owner><![CDATA[micksdevcorner@substack.com]]></googleplay:owner><googleplay:email><![CDATA[micksdevcorner@substack.com]]></googleplay:email><googleplay:author><![CDATA[Mickey M.]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Building a GPS Motorsports Performance Timer, Part 1]]></title><description><![CDATA[Lately, I&#8217;ve returned to hands-on projects by exploring new programming languages and experimenting with hardware, always chasing something with a learning curve.]]></description><link>https://www.micksdevcorner.com/p/building-a-gps-motorsports-performance</link><guid isPermaLink="false">https://www.micksdevcorner.com/p/building-a-gps-motorsports-performance</guid><dc:creator><![CDATA[Mickey M.]]></dc:creator><pubDate>Mon, 22 Jun 2026 01:17:15 GMT</pubDate><enclosure url="https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 424w, https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 848w, https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1272w, https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1456w" sizes="100vw"><img src="https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080" width="6016" height="4016" data-attrs="{&quot;src&quot;:&quot;https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:4016,&quot;width&quot;:6016,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;a computer screen with a bunch of code on it&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="a computer screen with a bunch of code on it" title="a computer screen with a bunch of code on it" srcset="https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 424w, https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 848w, https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1272w, https://images.unsplash.com/photo-1515879218367-8466d910aaa4?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwyfHxjb2Rpbmd8ZW58MHx8fHwxNzgyMDkwNDUzfDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Photo by <a href="https://unsplash.com/@cdr6934">Chris Ried</a> on <a href="https://unsplash.com">Unsplash</a></figcaption></figure></div><p>Lately, I&#8217;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 <a href="https://www.adafruit.com/product/4279"><span>Adafruit GPS module</span></a>.</p><p>My initial goal was straightforward: build a device to measure 0&#8211;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.</p><p><strong><span>Environment:</span></strong> Raspberry Pi 5, Raspberry Pi OS, Adafruit Ultimate GPS (USB, MTK3339 chipset), Python 3.14, pyserial + pynmea2.</p><h2>The hardware and the protocol</h2><p>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).</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;20099756-28b3-42bd-b7ed-e190ba443fb0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">$GPRMC,194530.000,A,3309.1234,N,08312.5678,W,0.27,68.40,180626,,,A*6F</code></pre></div><p>The 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&#8217;s design choice, as I will explain later.</p><p>One aside worth knowing up front: the <span>GP</span> in <span>$GPRMC</span> is the <em><span>talker ID</span></em>. A GPS-only fix is <span>GP</span>; a multi-constellation fix (GPS + GLONASS, say) comes through as <span>GN</span> &#8212; <span>$GNRMC</span>. Your parsing has to treat both the same, and as you&#8217;ll see, the library makes that easy.</p><h2>Step 1: Reading GPS Data</h2><p>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:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;b3efd1e6-ab85-4f49-969a-3362e26a00e7&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">import serial with serial.Serial(&#8221;/dev/ttyUSB0&#8221;, 9600, timeout=1) as ser:

    while True:

        line = ser.readline().decode(&#8221;ascii&#8221;, errors=&#8221;replace&#8221;).strip()

        if line:

            print(line)</code></pre></div><p>Setting <span>errors=&#8221;replace&#8221;</span> 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&#8217;ve simply intercepted the stream mid-sentence.</p><p>This simple viewer quickly became my go-to debug mode, revealing details most tutorials ignore. I saw that the many sentence types&#8212;RMC, GGA, GSA, GSV, VTG&#8212;were most irrelevant to my use case. It also showed that data starts flowing <strong><span>before a valid fix is obtained, with</span></strong> 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. </p><p>To make sense of the overwhelming stream of data, I color-coded each line by sentence type:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;fef90e11-4b24-4a7d-9abd-49ad7b67d236&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">import pynmea2

COLORS = {&#8221;RMC&#8221;: &#8220;\033[92m&#8221;, &#8220;GGA&#8221;: &#8220;\033[94m&#8221;, &#8220;GSA&#8221;: &#8220;\033[93m&#8221;,

          &#8220;GSV&#8221;: &#8220;\033[90m&#8221;, &#8220;VTG&#8221;: &#8220;\033[96m&#8221;}

RESET = &#8220;\033[0m&#8221;

def label(line: str) -&gt; str:

    try:

        stype = pynmea2.parse(line).sentence_type

    except pynmea2.ParseError:

        return &#8220;\033[91mRAW \033[0m&#8221;

    return f&#8221;{COLORS.get(stype, &#8216;&#8217;)}{stype:&lt;4}{RESET}&#8221;

# in the loop:  print(label(line), line)</code></pre></div><p>With this approach, issues like a dead feed or missing RMC sentences become immediately obvious, rather than getting lost in the scrollback.</p><h2>Step 2: Find the port without hardcoding it</h2><p><span>/dev/ttyUSB0</span> works until it doesn&#8217;t. Add a second serial device, switch to a Mac (where it&#8217;s <span>/dev/cu.usbserial-XXXX</span>), or use Windows (COM3), and the device path changes. I didn&#8217;t want users to have to guess, so I built a way to automatically find the module. The Adafruit board shows up as a <strong><span>CP2104 USB-to-UART bridge</span></strong>, thanks to the Silicon Labs chip. I scan available serial ports and match to find it.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;9924b601-53c4-4be2-b0ef-de0e4417fe96&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">import serial.tools.list_ports

def find_gps_port():

    for p in serial.tools.list_ports.comports():

        desc = (p.description or &#8220;&#8221;).lower()

        mfr  = (p.manufacturer or &#8220;&#8221;).lower()

        if any(k in desc or k in mfr for k in (&#8221;cp210&#8221;, &#8220;cp2104&#8221;, &#8220;adafruit&#8221;, &#8220;gps&#8221;, &#8220;uart&#8221;)):

            return p.device

    for p in serial.tools.list_ports.comports():       # fallback

        if &#8220;usb&#8221; in (p.device or &#8220;&#8221;).lower():

            return p.device

    return None</code></pre></div><p>This approach is a heuristic, not foolproof&#8212;a convenience that may need revisiting. When I ported the project to .NET, I found that System.IO.Ports doesn&#8217;t expose device descriptions or manufacturer strings, so the method wasn&#8217;t portable. That limitation shaped the project&#8217;s next phase.</p><p>A common stumbling block on Linux is needing to add your user to the <span>dialout</span> group (<span>sudo usermod --aG dialout $USER</span>). 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.</p><h2>Step 3: Configuring the module</h2><p>The default output is <strong><span>1 Hz</span></strong>, which is useless for launch timing. One sample per second brings up to a full second of error for your 0&#8211;60. I need 5&#8211;10 Hz. To set that, you write a PMTK command&#8212;the MTK3339&#8217;s configuration dialect&#8212;back <em><span>to</span></em> the module as a framed NMEA sentence:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;8f8c75ec-2498-4d2b-90a2-f765d50d6be4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">PMTK_SET_UPDATE_10HZ = b&#8221;$PMTK220,100*2F\r\n&#8221;    # 100 ms = 10 Hz

PMTK_SET_UPDATE_1HZ  = b&#8221;$PMTK220,1000*1F\r\n&#8221;   # 1000 ms = 1 Hz</code></pre></div><p>That <span>*2F</span> is a checksum, and it is <strong><span>not optional</span></strong>. Send a PMTK command with the wrong checksum, and the module silently ignores it &#8212; no error, no acknowledgment; it just keeps running at 1 Hz while you wonder why your &#8220;10 Hz&#8221; stream looks slow. The checksum is the XOR of every byte between <span>$</span> and <span>*</span>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;e4a50628-daf8-411f-9564-852c02ecb2ec&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">def nmea_checksum(payload: str) -&gt; str:

    c = 0

    for ch in payload:

        c ^= ord(ch)

    return f&#8221;{c:02X}&#8221;

# nmea_checksum(&#8221;PMTK220,100&#8221;) -&gt; &#8220;2F&#8221;</code></pre></div><p>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 <span>PMTK251</span>). 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.</p><p>One important detail: I reset the module to 1 Hz in <span>close()</span>, 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.</p><h2>Steps 4 &amp; 5: parsing NMEA and deriving speed</h2><p>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:</p><ul><li><p>Skip anything that doesn&#8217;t start with <span>$</span>.</p></li><li><p>Parse inside a <span>try/except pynmea2.ParseError</span>, because the stream <strong><span>will</span></strong> hand you junk.</p></li><li><p>Keep only <span>RMC</span>, the &#8220;recommended minimum&#8221; sentence, which carries position, speed-over-ground, course, and time in a single line. Checking <span>sentence_type == &#8220;RMC&#8221;</span> matches both <span>GPRMC</span> and <span>GNRMC</span>, so multi-constellation just works.</p></li><li><p>Require an active fix: <span>status == &#8220;A&#8221;</span>. A status of <span>&#8220;V&#8221;</span> means void; the receiver is talking, but doesn&#8217;t actually know where it is.</p></li></ul><p>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&#8217;s accuracy and responsiveness.</p><p>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.</p><p>Another note: the receiver will return <span>spd_over_grnd</span> in <strong><span>knots</span></strong>, not mph. So, we will need to convert that.</p><pre><code>speed_mph = float(msg.spd_over_grnd or 0) * 1.15078</code></pre><p>Including 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&#8217;t moving. That&#8217;s why I treat heading as optional, rather than defaulting to an artificial <span>0.0</span> north.</p><h2>Design Decisions</h2><p>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 <span>GPSPoint</span>, never on serial or NMEA:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;d8783745-36bc-49ae-b275-799ab54090ce&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">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() -&gt; 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 &gt;= 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) -&gt; 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) -&gt; 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})")</code></pre></div><p>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&#8217;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.</p><h2>Developing this without a moving car</h2><p>Nearly all of this development can be done at your desk. With the module stationary, you&#8217;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. </p><h2>Why Python was the right call</h2><p>Python enabled rapid iteration and provided a REPL that could interact directly with live hardware. Libraries like <span>pyserial</span> and <span>pynmea2</span> 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.</p><p>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.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://www.micksdevcorner.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading Mick's Dev Corner! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item></channel></rss>