Tick Slayer 3000
← All Documents

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.