build-guides
Guide 7: Capture & Navigation Software
Camera pipeline, GPS route recording/replay, heading estimation, steering controller, watchdog
Build Guide 7: Capture & Navigation Software
Type: Build Guide
The hardware is done. The rover drives, drags a cloth, and has a camera pointed at it. Now we write the software that makes it autonomous: capture images at intervals, record and replay GPS routes, monitor the battery, and handle the unexpected.
This is the longest guide because it's the core of what makes this a rover and not just an RC car with extra steps.
What You Need
- Fully assembled rover from Guides 1-6
- Laptop with SSH access
- An open outdoor area for GPS testing (>20m x 20m)
- A charged TRX-4 battery and Pi battery
Step 1: Capture Module
The camera needs to take a photo every 15 meters of cloth drag (per entomological protocol). The rover stops, captures, then continues.
# ~/rover/capture.py
"""Image capture module — stop-and-shoot at distance intervals."""
from picamera2 import Picamera2
from datetime import datetime
import os
import json
CAPTURE_DIR = "/home/pi/rover/captures"
class CaptureSystem:
def __init__(self, resolution=(4608, 2592)):
self.cam = Picamera2()
config = self.cam.create_still_configuration(
main={"size": resolution}
)
self.cam.configure(config)
self.cam.start()
self.capture_count = 0
def capture(self, lat, lon, battery_voltage):
"""Take a photo and save it with metadata."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"capture_{timestamp}_{self.capture_count:04d}.jpg"
filepath = os.path.join(CAPTURE_DIR, filename)
self.cam.capture_file(filepath)
self.capture_count += 1
# Save metadata sidecar
metadata = {
"filename": filename,
"timestamp": datetime.now().isoformat(),
"latitude": lat,
"longitude": lon,
"battery_voltage": battery_voltage,
"capture_number": self.capture_count,
}
meta_path = filepath.replace(".jpg", ".json")
with open(meta_path, "w") as f:
json.dump(metadata, f, indent=2)
return filepath
def shutdown(self):
self.cam.stop()
Test it:
python -c "
from capture import CaptureSystem
cap = CaptureSystem()
cap.capture(0.0, 0.0, 7.4)
cap.shutdown()
print('Check ~/rover/captures/')
"
Step 2: GPS Module
Continuous GPS reading in a background thread, with distance calculation:
# ~/rover/gps_reader.py
"""GPS reader — runs in background thread, provides current position."""
import serial
import pynmea2
import threading
import math
import time
class GPSReader:
def __init__(self, port="/dev/serial0", baud=9600):
self.serial = serial.Serial(port, baudrate=baud, timeout=1)
self.lat = 0.0
self.lon = 0.0
self.fix_quality = 0
self.satellites = 0
self._lock = threading.Lock()
self._running = False
def start(self):
self._running = True
self._thread = threading.Thread(target=self._read_loop, daemon=True)
self._thread.start()
def stop(self):
self._running = False
def _read_loop(self):
while self._running:
try:
line = self.serial.readline().decode('ascii', errors='replace').strip()
if line.startswith('$GPGGA'):
msg = pynmea2.parse(line)
with self._lock:
self.lat = msg.latitude
self.lon = msg.longitude
self.fix_quality = int(msg.gps_qual)
self.satellites = int(msg.num_sats)
except (pynmea2.ParseError, serial.SerialException):
time.sleep(0.1)
@property
def position(self):
with self._lock:
return (self.lat, self.lon)
@property
def has_fix(self):
with self._lock:
return self.fix_quality > 0
@staticmethod
def distance_meters(pos1, pos2):
"""Haversine distance between two (lat, lon) tuples."""
lat1, lon1 = math.radians(pos1[0]), math.radians(pos1[1])
lat2, lon2 = math.radians(pos2[0]), math.radians(pos2[1])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
return 6371000 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
Test it outdoors:
python -c "
from gps_reader import GPSReader
import time
gps = GPSReader()
gps.start()
for i in range(30):
print(f'Fix: {gps.has_fix}, Position: {gps.position}, Sats: {gps.satellites}')
time.sleep(1)
gps.stop()
"
Step 3: Battery Monitor
Continuous battery voltage reading via the ADS1115 and voltage divider with threshold alerts:
# ~/rover/battery.py
"""Battery voltage monitor via ADS1115 ADC."""
import board
import busio
import adafruit_ads1x15.ads1115 as ADS
from adafruit_ads1x15.analog_in import AnalogIn
class BatteryMonitor:
def __init__(self, divider_ratio=5.0, low_threshold=6.8, critical_threshold=6.4):
i2c = busio.I2C(board.SCL, board.SDA)
ads = ADS.ADS1115(i2c)
self.channel = AnalogIn(ads, ADS.P0)
self.divider_ratio = divider_ratio
self.low_threshold = low_threshold
self.critical_threshold = critical_threshold
@property
def voltage(self):
return self.channel.voltage * self.divider_ratio
@property
def is_low(self):
return self.voltage <= self.low_threshold
@property
def is_critical(self):
return self.voltage <= self.critical_threshold
Step 4: Route Recording
Before the rover can drive autonomously, someone needs to define the path. We record a route by walking/driving it with the GPS:
# ~/rover/route_recorder.py
"""Record a GPS route by walking/driving the path."""
from gps_reader import GPSReader
import json
import time
import os
ROUTE_DIR = "/home/pi/rover/routes"
def record_route(name, interval_seconds=1):
"""Record GPS positions at regular intervals. Ctrl+C to stop."""
gps = GPSReader()
gps.start()
print("Waiting for GPS fix...")
while not gps.has_fix:
time.sleep(1)
print(f"Fix acquired! Satellites: {gps.satellites}")
waypoints = []
print(f"Recording route '{name}'. Walk/drive the path. Ctrl+C to stop.")
try:
while True:
pos = gps.position
if pos[0] != 0.0: # Valid fix
waypoints.append({
"lat": pos[0],
"lon": pos[1],
"timestamp": time.time()
})
print(f" Waypoint {len(waypoints)}: {pos[0]:.6f}, {pos[1]:.6f}")
time.sleep(interval_seconds)
except KeyboardInterrupt:
pass
gps.stop()
# Save route
route = {
"name": name,
"recorded_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"waypoint_count": len(waypoints),
"waypoints": waypoints
}
filepath = os.path.join(ROUTE_DIR, f"{name}.json")
with open(filepath, "w") as f:
json.dump(route, f, indent=2)
print(f"\nSaved {len(waypoints)} waypoints to {filepath}")
return filepath
if __name__ == "__main__":
import sys
name = sys.argv[1] if len(sys.argv) > 1 else "test_route"
record_route(name)
Usage: Walk the route you want the rover to follow, carrying the Pi (or the whole rover):
python route_recorder.py backyard_loop
# Walk the path... Ctrl+C when done
Step 5: Route Replay (Navigation)
This is the core autonomy — follow a recorded route by steering toward each waypoint:
# ~/rover/navigator.py
"""Follow a recorded GPS route by steering toward waypoints."""
from gps_reader import GPSReader
import json
import math
class Navigator:
def __init__(self, route_file):
with open(route_file) as f:
route = json.load(f)
self.waypoints = [(wp["lat"], wp["lon"]) for wp in route["waypoints"]]
self.current_index = 0
self.waypoint_reached_distance = 3.0 # meters — GPS accuracy threshold
@property
def current_target(self):
if self.current_index < len(self.waypoints):
return self.waypoints[self.current_index]
return None
@property
def is_complete(self):
return self.current_index >= len(self.waypoints)
def bearing_to_target(self, current_pos):
"""Calculate bearing from current position to target waypoint."""
if self.current_target is None:
return 0
lat1 = math.radians(current_pos[0])
lon1 = math.radians(current_pos[1])
lat2 = math.radians(self.current_target[0])
lon2 = math.radians(self.current_target[1])
dlon = lon2 - lon1
x = math.sin(dlon) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
bearing = math.degrees(math.atan2(x, y))
return (bearing + 360) % 360
def update(self, current_pos):
"""Check if we've reached the current waypoint, advance if so.
Returns (bearing_to_target, distance_to_target, waypoint_index)."""
if self.is_complete:
return (0, 0, -1)
distance = GPSReader.distance_meters(current_pos, self.current_target)
if distance <= self.waypoint_reached_distance:
self.current_index += 1
if self.is_complete:
return (0, 0, -1)
bearing = self.bearing_to_target(current_pos)
return (bearing, distance, self.current_index)
Important: GPS has ~2-3m precision, so we consider a waypoint "reached" at 3 meters. Trying to hit waypoints more precisely will cause the rover to circle endlessly.
Step 6: Heading Estimation
We don't have a compass, so we estimate heading from consecutive GPS positions:
# ~/rover/heading.py
"""Estimate rover heading from GPS movement."""
import math
from collections import deque
class HeadingEstimator:
def __init__(self, min_distance=1.0):
self.history = deque(maxlen=5)
self.min_distance = min_distance
self._heading = 0
def update(self, position):
"""Update heading estimate with new GPS position."""
if len(self.history) > 0:
last = self.history[-1]
# Only update if we've moved enough for a reliable bearing
dlat = position[0] - last[0]
dlon = position[1] - last[1]
dist = math.sqrt(dlat**2 + dlon**2) * 111000 # Rough meters
if dist >= self.min_distance:
self._heading = math.degrees(math.atan2(dlon, dlat)) % 360
self.history.append(position)
else:
self.history.append(position)
@property
def heading(self):
return self._heading
Limitation: This only works when the rover is moving. At standstill, heading is unknown. This means the rover needs to creep forward a few meters at the start of a route to establish direction.
Step 7: Steering Controller
Convert "I need to go at bearing X but I'm facing bearing Y" into a steering angle:
# ~/rover/steering_controller.py
"""Convert desired bearing to steering commands."""
class SteeringController:
def __init__(self, center, left_max, right_max):
self.center = center
self.left_max = left_max
self.right_max = right_max
def compute_steering(self, current_heading, target_bearing):
"""Returns a servo angle given current heading and desired bearing."""
# How far off are we?
error = target_bearing - current_heading
# Normalize to -180 to +180
while error > 180:
error -= 360
while error < -180:
error += 360
# Map error to steering angle
# Positive error = target is to our right = increase angle
# Negative error = target is to our left = decrease angle
if error > 0:
# Turn right — proportional, capped at right_max
steer = self.center + min(error, 45) / 45 * (self.right_max - self.center)
else:
# Turn left — proportional, capped at left_max
steer = self.center + max(error, -45) / 45 * (self.center - self.left_max)
return max(self.left_max, min(self.right_max, steer))
Step 8: Watchdog Timer
If the Pi crashes, loses WiFi, or the control script dies, we don't want the rover driving into the neighbor's yard. The watchdog stops the truck if no command is sent within a timeout:
# ~/rover/watchdog.py
"""Safety watchdog — stops the rover if no heartbeat received."""
import threading
import time
class Watchdog:
def __init__(self, timeout_seconds=2.0, on_timeout=None):
self.timeout = timeout_seconds
self.on_timeout = on_timeout
self._last_heartbeat = time.time()
self._running = False
def start(self):
self._running = True
self._last_heartbeat = time.time()
self._thread = threading.Thread(target=self._monitor, daemon=True)
self._thread.start()
def stop(self):
self._running = False
def heartbeat(self):
"""Call this regularly from the main control loop."""
self._last_heartbeat = time.time()
def _monitor(self):
while self._running:
if time.time() - self._last_heartbeat > self.timeout:
if self.on_timeout:
self.on_timeout()
self._running = False
return
time.sleep(0.1)
Step 9: Test Each Module
Before combining everything, test each module individually outdoors:
GPS route recording:
cd ~/rover
python route_recorder.py test_route
# Walk a small loop (~20m). Ctrl+C.
# Check: routes/test_route.json should have waypoints with non-zero lat/lon
Heading estimation:
python -c "
from gps_reader import GPSReader
from heading import HeadingEstimator
import time
gps = GPSReader()
gps.start()
he = HeadingEstimator()
# Walk in a straight line with the Pi
for i in range(60):
if gps.has_fix:
he.update(gps.position)
print(f'Heading: {he.heading:.0f}° Position: {gps.position}')
time.sleep(1)
gps.stop()
"
Walk in a known direction (north, along your driveway, etc.) and check if the heading estimate makes sense.
Capture at intervals:
Drive the rover manually (Guide 5's drive_manual.py) while a capture loop runs:
python -c "
from capture import CaptureSystem
from gps_reader import GPSReader
import time
cap = CaptureSystem()
gps = GPSReader()
gps.start()
for i in range(5):
pos = gps.position
cap.capture(pos[0], pos[1], 7.4)
print(f'Captured #{i+1} at {pos}')
time.sleep(10)
cap.shutdown()
gps.stop()
"
Check the captures directory — you should have 5 images with JSON sidecars.
Checklist
- Capture module takes photos with GPS/battery metadata
- GPS reader gets a fix and streams positions
- Battery monitor reads voltage correctly via ADC
- Route recorder saves a walkable route as JSON
- Heading estimator tracks direction during movement
- Steering controller converts bearing error to servo angle
- Watchdog triggers emergency stop after timeout
- Each module tested individually outdoors
Almost there. Guide 8: Data Pipeline & First Run ties everything together into a single autonomous mission and connects the data flow to your web app.
