IoT Projects

Project: Asset and Fleet Tracking

Build a GPS asset tracker with ESP32 and NEO-6M, parse NMEA sentences, implement geofencing with the Haversine formula, and visualize live location on a map dashboard.

Project: Asset and Fleet Tracking

GPS tracking is one of the most commercially valuable IoT applications — from delivery fleets to construction equipment. This project builds a complete tracker: GPS data acquisition, geofencing, cellular transmission, and a live map dashboard.


Hardware Setup

┌─────────────────────────────────────┐
│         ESP32 Tracker               │
│                                     │
│  ├── NEO-6M GPS module (UART2)      │
│  ├── SIM800L / SIM7600 (UART1)      │  ← Optional cellular
│  ├── 18650 LiPo battery             │
│  ├── TP4056 charger                 │
│  └── GPS antenna (external)         │
│                                     │
│  Wiring:                            │
│  GPS TX → ESP32 GPIO16 (RX2)       │
│  GPS RX → ESP32 GPIO17 (TX2)       │
│  GPS VCC → 3.3V                     │
│  GPS GND → GND                      │
└─────────────────────────────────────┘

GPS Module Basics

The NEO-6M outputs NMEA 0183 sentences at 9600 baud — a standardized text protocol.

Common NMEA sentences:
  $GPRMC  — Recommended Minimum data (lat, lon, speed, heading, time)
  $GPGGA  — GPS fix data (lat, lon, altitude, satellites)
  $GPGSV  — Satellites in view

Example GPRMC sentence:
$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
       │       │ │         │ │          │ │     │     │       │
       │       │ │         │ │          │ │     │     │       Magnetic variation
       │       │ │         │ │          │ │     │     Date (23 March 1994)
       │       │ │         │ │          │ │     Heading (degrees)
       │       │ │         │ │          │ Speed (knots)
       │       │ │         │ │          Longitude
       │       │ │         │ Lon direction (E)
       │       │ │         Latitude
       │       │ Validity (A=active/fix, V=void/no fix)
       │       Time (12:35:19 UTC)
       Sentence type

GPS Parsing (MicroPython)

python
# gps.py — GPS driver for NEO-6M via UART
from machine import UART
import utime

class GPS:
    def __init__(self, tx_pin=17, rx_pin=16, baud=9600):
        self._uart     = UART(2, baudrate=baud, tx=tx_pin, rx=rx_pin)
        self._lat      = None
        self._lon      = None
        self._speed    = None
        self._heading  = None
        self._altitude = None
        self._sats     = 0
        self._fix      = False
        self._fix_time = None
        self._buf      = b''

    def _parse_nmea(self, sentence):
        """Parse one NMEA sentence. Returns True if position updated."""
        if not sentence.startswith('$'):
            return False

        # Validate checksum
        if '*' in sentence:
            data, checksum = sentence.rsplit('*', 1)
            calc = 0
            for c in data[1:]:
                calc ^= ord(c)
            if f"{calc:02X}" != checksum.strip().upper():
                return False

        parts = sentence.split(',')
        kind  = parts[0][1:]  # Remove $

        if kind == "GPRMC" or kind == "GNRMC":
            return self._parse_rmc(parts)
        elif kind == "GPGGA" or kind == "GNGGA":
            return self._parse_gga(parts)
        return False

    def _parse_rmc(self, parts):
        if len(parts) < 8 or parts[2] != 'A':
            self._fix = False
            return False

        self._lat     = self._nmea_to_decimal(parts[3], parts[4])
        self._lon     = self._nmea_to_decimal(parts[5], parts[6])
        self._speed   = float(parts[7]) * 1.852 if parts[7] else None  # knots → km/h
        self._heading = float(parts[8]) if parts[8] else None
        self._fix     = True
        self._fix_time = parts[1]
        return True

    def _parse_gga(self, parts):
        if len(parts) < 10 or parts[6] == '0':
            return False
        self._altitude = float(parts[9]) if parts[9] else None
        self._sats     = int(parts[7]) if parts[7] else 0
        return False  # Don't update lat/lon here (use RMC)

    def _nmea_to_decimal(self, value, direction):
        """Convert NMEA DDMM.MMMM to decimal degrees."""
        if not value:
            return None
        dot = value.index('.')
        degrees = int(value[:dot - 2])
        minutes = float(value[dot - 2:])
        decimal = degrees + minutes / 60
        if direction in ('S', 'W'):
            decimal = -decimal
        return round(decimal, 6)

    def update(self, timeout_ms=1000):
        """Read and parse NMEA data. Returns True if position updated."""
        deadline = utime.ticks_add(utime.ticks_ms(), timeout_ms)
        updated  = False

        while utime.ticks_diff(deadline, utime.ticks_ms()) > 0:
            if self._uart.any():
                byte = self._uart.read(1)
                if byte == b'\n':
                    try:
                        sentence = self._buf.decode('ascii').strip()
                        if self._parse_nmea(sentence):
                            updated = True
                    except:
                        pass
                    self._buf = b''
                elif byte != b'\r':
                    self._buf += byte
        return updated

    @property
    def location(self):
        if self._fix:
            return {
                "lat":      self._lat,
                "lon":      self._lon,
                "speed_kmh": round(self._speed, 1) if self._speed else None,
                "heading":  self._heading,
                "altitude": self._altitude,
                "sats":     self._sats,
                "fix":      True
            }
        return {"fix": False}

Geofencing

python
import math

def haversine(lat1, lon1, lat2, lon2):
    """
    Calculate great-circle distance between two GPS coordinates.
    Returns distance in meters.
    """
    R = 6371000  # Earth radius in meters

    phi1    = math.radians(lat1)
    phi2    = math.radians(lat2)
    dphi    = math.radians(lat2 - lat1)
    dlambda = math.radians(lon2 - lon1)

    a = (math.sin(dphi / 2) ** 2 +
         math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2)
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))

    return round(R * c, 1)  # Meters

class Geofence:
    def __init__(self, name, center_lat, center_lon, radius_m):
        self.name      = name
        self.center    = (center_lat, center_lon)
        self.radius_m  = radius_m
        self._was_inside = None

    def check(self, lat, lon):
        """
        Returns:
          'inside'   — currently inside geofence
          'outside'  — currently outside geofence
          'entered'  — just entered
          'exited'   — just exited
        """
        distance = haversine(lat, lon, self.center[0], self.center[1])
        inside   = distance <= self.radius_m

        if self._was_inside is None:
            self._was_inside = inside
            return 'inside' if inside else 'outside'

        if inside and not self._was_inside:
            self._was_inside = True
            return 'entered'
        elif not inside and self._was_inside:
            self._was_inside = False
            return 'exited'

        return 'inside' if inside else 'outside'

# Define geofences
zones = [
    Geofence("Office", lat=12.9716, lon=77.5946, radius_m=200),
    Geofence("Warehouse", lat=12.9611, lon=77.6387, radius_m=500),
    Geofence("Restricted Zone", lat=12.9800, lon=77.5900, radius_m=100),
]

def check_all_zones(lat, lon):
    events = []
    for zone in zones:
        status = zone.check(lat, lon)
        if status in ('entered', 'exited'):
            events.append({"zone": zone.name, "event": status})
            print(f"Geofence event: {status.upper()} {zone.name}")
    return events

Complete Tracker Main Loop

python
# tracker_main.py
from gps import GPS
import ujson, utime, machine, network
from umqtt.simple import MQTTClient

gps       = GPS(tx_pin=17, rx_pin=16)
DEVICE_ID = "truck-007"
MQTT_BROKER = "broker.example.com"
TOPIC     = f"fleet/{DEVICE_ID}/location"

REPORT_INTERVAL_MOVING  = 10   # seconds when moving
REPORT_INTERVAL_PARKED  = 120  # seconds when parked
SPEED_THRESHOLD_KMH     = 5    # Below this = parked

last_location = None
last_report   = 0

def is_moving(speed):
    return speed and speed > SPEED_THRESHOLD_KMH

def connect_and_send(payload):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect("MySSID", "MyPassword")
    for _ in range(10):
        if wlan.isconnected(): break
        utime.sleep_ms(500)

    if not wlan.isconnected():
        return False

    try:
        c = MQTTClient(DEVICE_ID, MQTT_BROKER, keepalive=30)
        c.connect()
        c.publish(TOPIC, ujson.dumps(payload), retain=True)
        c.disconnect()
        return True
    finally:
        wlan.active(False)

while True:
    gps.update(timeout_ms=5000)
    loc = gps.location

    if not loc["fix"]:
        print("Waiting for GPS fix...")
        utime.sleep(5)
        continue

    now = utime.time()
    moving = is_moving(loc.get("speed_kmh"))
    interval = REPORT_INTERVAL_MOVING if moving else REPORT_INTERVAL_PARKED

    # Check geofences
    events = check_all_zones(loc["lat"], loc["lon"])

    if events or (now - last_report >= interval):
        payload = {
            "device_id":  DEVICE_ID,
            "lat":        loc["lat"],
            "lon":        loc["lon"],
            "speed_kmh":  loc.get("speed_kmh"),
            "heading":    loc.get("heading"),
            "altitude":   loc.get("altitude"),
            "sats":       loc.get("sats"),
            "moving":     moving,
            "events":     events,
            "timestamp":  now
        }
        if connect_and_send(payload):
            last_report = now
            print(f"Sent: {loc['lat']}, {loc['lon']} | {loc.get('speed_kmh')} km/h")

    utime.sleep(2)

Live Map Dashboard (Python + Folium)

python
# map_server.py — serve a live map of all tracked assets
from flask import Flask, render_template, jsonify
import paho.mqtt.client as mqtt
import json
from datetime import datetime

app = Flask(__name__)
fleet = {}  # device_id → latest location + history

def on_message(client, userdata, msg):
    data = json.loads(msg.payload)
    device_id = data.get("device_id")
    if device_id and data.get("lat"):
        if device_id not in fleet:
            fleet[device_id] = {"current": None, "history": []}
        fleet[device_id]["current"] = {
            "lat": data["lat"],
            "lon": data["lon"],
            "speed": data.get("speed_kmh"),
            "heading": data.get("heading"),
            "timestamp": datetime.utcnow().isoformat()
        }
        fleet[device_id]["history"].append([data["lat"], data["lon"]])
        if len(fleet[device_id]["history"]) > 100:
            fleet[device_id]["history"].pop(0)

client = mqtt.Client("map-server")
client.on_connect = lambda c, u, f, r: c.subscribe("fleet/#", qos=1)
client.on_message = on_message
client.connect("localhost", 1883, 60)
client.loop_start()

@app.route('/api/fleet')
def get_fleet():
    return jsonify(fleet)

@app.route('/')
def map_view():
    return """
    <!DOCTYPE html>
    <html><head>
      <title>Fleet Tracker</title>
      <link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css"/>
      <script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
    </head>
    <body style="margin:0">
      <div id="map" style="height:100vh"></div>
      <script>
        const map = L.map('map').setView([12.97, 77.59], 12);
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
        const markers = {};

        async function refresh() {
          const data = await fetch('/api/fleet').then(r => r.json());
          for (const [id, info] of Object.entries(data)) {
            if (!info.current) continue;
            const {lat, lon, speed, heading} = info.current;
            if (markers[id]) {
              markers[id].setLatLng([lat, lon])
                .setPopupContent(`${id}<br>${speed ?? '?'} km/h`);
            } else {
              markers[id] = L.marker([lat, lon])
                .bindPopup(`${id}<br>${speed ?? '?'} km/h`)
                .addTo(map);
            }
          }
        }

        refresh();
        setInterval(refresh, 5000);
      </script>
    </body></html>
    """

app.run(host='0.0.0.0', port=5000)

Summary

  • NEO-6M GPS sends NMEA sentences at 9600 baud; parse $GPRMC for lat/lon/speed/heading
  • Use _nmea_to_decimal() to convert NMEA DDMM.MMMM format to decimal degrees
  • Haversine formula computes great-circle distance accurately for geofencing
  • Detect zone enter/exit events by comparing current vs previous inside/outside state
  • Report more frequently when moving (10s) and less when parked (2min) to save data and battery
  • A Leaflet.js map with a polling endpoint gives you a live fleet map in ~50 lines of HTML

Next, we'll explore Industrial IoT — Modbus, OPC-UA, and predictive maintenance at scale.