Back to resources
HIL
Testing
Firmware

Testing GPS Firmware Without the Sky

A CI bench is indoors, so a real GPS module never gets a fix. The BenchPod plays the module on real UART wires instead, so a test can drive, cross a geofence, lose the fix in a tunnel and roll over midnight on every commit.

Edward Viaene · November 2, 2026 · 5 min read

GPS firmware is easy to get almost right. The parser handles the sentences from the module on your desk, the tracker reports a position, and the demo works. Then the device spends a week in a truck. It loses the fix in a tunnel, comes back with a jump in position, crosses midnight UTC, and the cases nobody tested turn into support tickets.

None of that happens on a CI bench. The bench is indoors, so a real module never gets a fix, and even outdoors you can't make it drive at 90 km/h or lose the sky for 40 seconds on demand.

So the BenchPod becomes the GPS module. It prints NMEA sentences on a real wire into the DUT's UART, and your test decides what they say.

A GPS module is just a UART

To the firmware, most GPS modules are a UART that prints text. Once a second, at 9600 baud, the module sends a burst of NMEA 0183 sentences: RMC (position, speed, date), GGA (fix quality, satellites, altitude), GSA, GSV, VTG and GLL. The firmware parses them, checks the checksums and decides whether it has a fix.

The pod does the same: one LA channel wired to the DUT's RX, the sentences a common module sends out of the box, the same baud and the same one-second rhythm. It sends them from a second UART, so the DUT's console keeps working on the first one. Your test can talk to the firmware and feed it GPS data at the same time.

The examples below use a tracker firmware with a simple text console. Swap in your own commands and log lines:

import time
from embeddedci.benchpod import BenchPod, INTERNAL, Sensor

with BenchPod("192.168.1.213", la_voltage=3.3) as bp:
    bp.enable_gps(9, latitude_deg=50.8466, longitude_deg=4.3528,
                  altitude_m=56, satellites=9, hdop=0.9)

    with bp.open_uart(rx=5, tx=6) as console:
        bp.power_on(INTERNAL)
        console.expect("GPS fix=1", timeout=10)
        console.write("pos\n")
        console.expect("lat=50.846", timeout=2)

enable_gps takes the channel the DUT's RX is on (here LA9) and the starting fix. From then on the pod prints a fresh fix every second until the test stops it.

A fix that moves

A fixed position tests the parser. A tracker needs to see movement. Give the receiver a speed and a course, and the pod moves the position between fixes, like a vehicle driving in a straight line:

bp.set_gps(speed_kmh=90, course_deg=45)            # northeast on the highway
time.sleep(10)
console.write("trip\n")
console.expect(r"distance_m=2[45]\d", timeout=2)   # 90 km/h for 10 s = 250 m

That one assertion checks the parser, the distance math and how the firmware handles a position that changes every second. Change the course and you have a turn. Move the position in one step with set_gps(latitude_deg=..., longitude_deg=...) and you have the jump a receiver can make after a cold start, which a good tracker should filter out instead of logging a 300 km trip.

Geofences, both ways

Geofence logic is the classic case of code that ships with half its branches untested. With an emulated receiver, crossing the fence takes two lines:

bp.set_gps(latitude_deg=50.8466, longitude_deg=4.3528, speed_kmh=0)   # inside the depot
console.expect("ZONE inside=depot", timeout=3)

bp.set_gps(latitude_deg=50.9000)                                       # 6 km north
console.expect("EVT geofence exit=depot", timeout=3)

Go back the other way and assert the enter event. Park right on the boundary and check that the hysteresis keeps it from toggling. It's all deterministic, so it runs on every commit.

Losing the sky

fix=0 turns the receiver into one that has lost its lock. The sentences keep coming, like they do from a real module, but with empty position fields (RMC status V, GGA quality 0). That's exactly what the firmware sees in a tunnel:

bp.set_gps(fix=0, satellites=0)                # into the tunnel
console.expect("EVT gps_lost", timeout=5)

time.sleep(40)

bp.set_gps(fix=1, satellites=7)                # back out
console.expect("EVT gps_restored", timeout=5)

Does the firmware keep the last good position or report zeros? Does it pause the trip counter, or count the gap as a jump? Does its "no fix" timeout fire on time? Each of those is now one test. A weak fix is one line too: satellites=3, hdop=8.5 is a fix the firmware probably shouldn't trust for a geofence decision.

Time is part of the fix

Plenty of devices set their clock from GPS, so the UTC in RMC is an input worth testing on its own. By default the pod prints the current time, but utc= sets any time you like, and the clock runs from there:

bp.set_gps(utc="2026-12-31T23:59:55Z")         # five seconds to New Year (UTC)
time.sleep(8)
console.write("clock\n")
console.expect("date=2027-01-01", timeout=2)

Midnight, month ends, leap years and year ends are all one utc= away. Those are the bugs that otherwise show up once a year, in the field.

Not every module sends the same thing

Some modules are set up to send only RMC and GGA. Some run at 5 or 10 Hz, some at 115200 baud. The receiver takes all of that, so you can check that the firmware doesn't depend on a sentence its production module never sends:

bp.enable_gps(9, sentences="RMC,GGA", rate_hz=10, baud=115200)

If a combination can't fit on the wire (ten full fixes a second at 9600 baud, say), the pod refuses it and tells you what to lower.

Add an accelerometer

A tracker rarely relies on GPS alone. It sleeps until an accelerometer reports movement, flags a crash from a spike in g, or notices it's upside down. The pod can emulate the I2C sensor too, at the same time, on two other channels:

bp.enable_pullup(1, 2)
bp.enable_i2c_sensor(Sensor.MPU6050, sda=1, scl=2, accel_z_g=1.0)   # lying still

bp.set_i2c_sensor(accel_x_g=0.3)                  # the truck starts moving
console.expect("EVT motion", timeout=2)
bp.set_gps(speed_kmh=30, course_deg=180)          # and the fix follows

bp.set_i2c_sensor(accel_x_g=-4.0)                 # hard braking
console.expect("EVT harsh_brake", timeout=2)

Now one test tells the whole story: the device wakes on motion, picks up a moving fix, flags a hard brake and keeps the trip consistent. Doing that for real means driving a vehicle, and you can't rerun the drive on every commit.

What it does and doesn't cover

The pod emulates the module's serial output, not the satellite signals. That covers your firmware's parsing, state machines, timing and logic over real wires, which is where most GPS bugs live. It doesn't test the antenna, the RF front end or the module's own acquisition; for that you need an RF simulator, at a very different price.

The receiver needs firmware 3.8.0 or later. The same calls are in the MCP server (enable_gps, set_gps, gps_status), so an agent can drive a tracker through a scenario too, and the web app's Sensors tab has a GPS receiver card to try it by hand. The full reference is in the pytest framework docs.