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 typeGPS 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 eventsComplete 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
$GPRMCfor lat/lon/speed/heading - Use
_nmea_to_decimal()to convert NMEADDMM.MMMMformat 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.