1. 项目概述:从一次骑行遗憾到动手实现

几周前我参加了一次骑行活动,结束后特别想复盘一下自己的路线和速度变化,看看哪里爬坡慢了,哪段平路可以再冲一冲。可惜当时手头没有合适的记录设备,这个想法也就落了空。这件事一直让我惦记着,作为一个喜欢折腾硬件的开发者,与其等待,不如自己动手做一个。于是,就有了这个基于ESP32和Python的DIY GPS轨迹记录与分析系统。

这个项目的核心目标很明确:制作一个便携、低功耗的硬件设备,能够长时间、高精度地记录运动轨迹(经纬度、时间戳),并将原始数据保存下来;然后,通过PC端的Python程序,对这些数据进行处理、分析和可视化,最终生成清晰的路线地图以及速度、距离随时间变化的图表。它非常适合骑行、徒步、跑步爱好者,或者任何需要对移动物体进行轨迹追踪和数据分析的场景。对于嵌入式开发或物联网入门的学习者来说,这也是一个绝佳的综合性实践项目,涵盖了硬件选型、嵌入式编程、串口通信、文件系统和数据可视化等多个关键环节。

2. 核心硬件选型与设计思路

为什么选择ESP32和A9G这套组合?这背后是基于成本、性能、开发便利性和项目需求的综合考量。市面上GPS模块很多,从单纯的NMEA协议输出模块到集成GPRS的物联网模块,选择非常丰富。

2.1 主控单元:ESP32的优势

ESP32在这类项目中几乎是首选。首先,它双核240MHz的主频处理GPS数据流绰绰有余。其次,它丰富的GPIO和标准的SPI、I2C、UART接口,使得连接各种外设(如本项目中的SD卡模块)变得非常轻松。最重要的是,ESP32支持MicroPython,这意味着我们可以用Python语言来编写嵌入式固件,极大地降低了开发门槛,也让PC端(Python)和硬件端(MicroPython)的数据处理逻辑能够保持高度一致,减少了思维转换的成本。当然,它的低功耗特性在电池供电的设备中也至关重要。

2.2 定位与通信模块:A9G的集成方案

A9G模块是一个集成了GPS和GPRS功能的SoC。在这个项目中,我们主要利用其GPS功能。选择它而不是一个单纯的GPS模块,主要是看中了其“All-in-One”的设计,为项目后续扩展(比如通过GPRS实时上传位置)预留了可能性。A9G通过串口(UART)与ESP32通信,输出标准的NMEA-0183协议数据,这是全球GPS接收器的通用语言,兼容性极好。其内置的陶瓷天线在户外开阔环境下能有不错的搜星和定位效果。

注意: A9G模块需要独立供电和控制其开关机。电路设计中,我们使用ESP32的两个GPIO引脚分别连接A9G的 PWR_KEY RESET 引脚,通过特定的时序拉低或拉高这些引脚来实现模块的硬启动,这是确保模块可靠工作的关键一步,后续固件部分会详细说明。

2.3 数据存储:SD卡模块的必要性

轨迹记录会产生持续不断的数据流。虽然ESP32本身有Flash存储,但容量有限且频繁写入会影响寿命。因此,外接一个SPI接口的Micro SD卡模块用于存储原始NMEA数据是更可靠、更通用的方案。SD卡容量大(通常16GB或32GB),可以记录长达数十甚至上百小时的轨迹数据,并且生成的文件可以直接在电脑上读取,方便后续处理。

2.4 整体硬件连接框架

整个系统的硬件连接清晰而简洁:

  1. 电源 :整个系统由一块3.7V锂电池通过MakePython ESP32开发板上的充放电管理电路供电,同时也可以通过Micro USB接口供电和调试。
  2. 核心通信 :ESP32的某个UART(例如UART2)的TX、RX引脚分别连接A9G模块的RX、TX,实现双向AT命令与数据通信。
  3. 存储扩展 :ESP32的HSPI或VSPI(例如SPI1)接口连接SD卡模块,包括 SCK MOSI MISO CS 引脚。
  4. 控制引脚 :ESP32的两个GPIO(如GPIO33和GPIO27)分别连接A9G的 RESET PWR_KEY ,用于控制模块复位和开关。

这种设计保证了各司其职:ESP32作为大脑进行调度和初步处理,A9G专心负责定位,SD卡安心存储数据。

3. 嵌入式端固件开发详解

硬件搭建好后,我们需要让ESP32“活”起来。这里我们使用MicroPython进行开发,其交互式特性和丰富的库支持能加速开发进程。

3.1 开发环境搭建与固件烧录

首先需要在电脑上安装ESP32的MicroPython固件。我推荐使用 Thonny IDE或者原项目提到的 uPyCraft 。以Thonny为例,过程非常直观:

  1. 用USB线连接ESP32开发板。
  2. 打开Thonny,在右下角选择正确的解释器和端口(如 ESP32 COMx )。
  3. 如果开发板是空的,需要先烧录固件。在Thonny的“运行”菜单中选择“选择解释器”,点击“安装或更新MicroPython”,选择对应的ESP32固件文件( .bin 格式)和端口,点击安装即可。

烧录成功后,Thonny的Shell窗口会出现MicroPython的 >>> 提示符,此时可以输入 print(“hello”) 进行测试。这标志着ESP32已经准备好了运行我们的Python代码。

3.2 SD卡初始化与文件系统挂载

数据存储是核心功能之一。我们需要在MicroPython中初始化SPI总线并挂载SD卡为文件系统。

import machine, os, sdcard, time
from machine import Pin, SPI

# 初始化SPI1用于连接SD卡模块 (引脚根据你的实际连接调整)
# HSPI (SPI1) 的默认引脚是:sck=14, mosi=13, miso=12
spi = SPI(1, baudrate=1000000, polarity=0, phase=0, sck=Pin(14), mosi=Pin(13), miso=Pin(12))
sd = sdcard.SDCard(spi, Pin(32)) # Pin(32) 是SD卡的片选引脚CS

# 创建文件系统实例并挂载到 “/sd” 目录
vfs = os.VfsFat(sd)
try:
    os.mount(vfs, “/sd”)
    print(“SD card mounted successfully.”)
except OSError as e:
    print(“Failed to mount SD card:”, e)

挂载成功后,我们就可以像操作普通文件一样,使用 open(‘/sd/trace.txt’, ‘a’) 来在SD卡上创建和追加写入文件了。

3.3 A9G模块的驱动与控制

A9G模块通过AT指令进行控制。我们需要通过UART向其发送指令,并读取回复。首先需要初始化UART并按照时序启动模块。

from machine import UART, Pin
import time

# 初始化UART2与A9G通信,波特率通常为115200
uart = UART(2, baudrate=115200, tx=17, rx=16, timeout=1000)

# 控制A9G启动的引脚
A9G_PWR_KEY = Pin(27, Pin.OUT)
A9G_RESET_PIN = Pin(33, Pin.OUT)

def a9g_power_on():
    “”“严格的A9G上电时序,确保模块正常启动”“”
    A9G_RESET_PIN.value(0)  # 确保复位引脚为低
    A9G_PWR_KEY.value(0)    # 确保PWR_KEY为低
    time.sleep(1)
    A9G_PWR_KEY.value(1)    # 拉高PWR_KEY至少1秒
    time.sleep(2)
    A9G_PWR_KEY.value(0)    # 再拉低
    print(“A9G Power ON sequence sent.”)
    # 等待模块启动,发送AT指令测试
    time.sleep(5)
    uart.write(“AT\r\n”)
    time.sleep(0.1)
    if uart.any():
        response = uart.read().decode()
        print(“A9G Response:”, response)

def enable_gps():
    “”“开启GPS功能”“”
    uart.write(“AT+GPS=1\r\n”)  # 开启GPS
    time.sleep(1)
    uart.write(“AT+GPSRD=1\r\n”) # 设置GPS数据自动上报,例如每秒一次
    time.sleep(1)
    # 可以添加读取回复并判断是否成功的逻辑

实操心得: A9G模块的启动时序非常关键。如果 PWR_KEY 引脚的高电平脉冲时间不够长,或者 RESET 引脚状态不对,都可能导致模块无法正常启动。务必参考模块数据手册的时序图。另外,发送AT指令后,最好增加读取和解析回复的代码,以确认指令执行成功,提高程序的健壮性。

3.4 主循环逻辑:数据采集与存储

核心逻辑是一个循环,不断读取A9G通过UART上报的NMEA数据,筛选出有用的 $GPRMC (推荐最小定位信息)或 $GPGGA (全球定位系统定位数据)语句,并加上时间戳后写入SD卡。

def main_loop():
    a9g_power_on()
    enable_gps()
    
    # 在SD卡上创建一个以时间命名的文件
    filename = “/sd/trace_{}.txt”.format(int(time.time()))
    with open(filename, ‘w’) as f:
        f.write(“# GPS Trace Log Started\n”)
    
    while True:
        if uart.any():
            raw_data = uart.readline()
            if raw_data:
                try:
                    line = raw_data.decode(‘utf-8’).strip()
                except:
                    continue
                
                # 筛选GPRMC语句(包含时间、日期、位置、速度)
                if line.startswith(‘$GPRMC’):
                    # 获取当前系统时间戳(ESP32的RTC需要先通过网络或GPS自身校准)
                    timestamp = time.time()
                    log_entry = “{}, {}\n”.format(timestamp, line)
                    with open(filename, ‘a’) as f:
                        f.write(log_entry)
                    print(“Logged:”, log_entry[:50]) # 打印前50字符便于监控
        # 短暂延时,防止循环过快消耗CPU
        time.sleep(0.05)

这个循环会一直运行,直到设备断电。写入SD卡时使用 ‘a’ (追加)模式,并确保每次写入后文件正确关闭(使用 with open() 语句会自动管理),以防止数据丢失。

4. PC端Python数据分析与可视化全流程

硬件记录下原始数据后,真正的“分析”大戏在电脑上上演。我们使用Python,因为它拥有无与伦比的数据处理和可视化库生态。

4.1 原始数据解析:从NMEA到经纬度

SD卡中的 trace.txt 文件每一行可能混合了各种NMEA语句和我们的时间戳。第一步是解析它。

import pandas as pd

def parse_nmea_to_df(file_path):
    “”“将记录的NMEA文件解析为Pandas DataFrame”“”
    data_list = []
    with open(file_path, ‘r’) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith(‘#’):
                continue
            # 假设我们的格式是 “timestamp, $GPRMC,...”
            parts = line.split(‘, ‘, 1)
            if len(parts) != 2:
                continue
            timestamp_str, nmea_sentence = parts
            if not nmea_sentence.startswith(‘$GPRMC’):
                continue
                
            # 解析GPRMC语句
            fields = nmea_sentence.split(‘,’)
            if len(fields) < 10 or fields[2] != ‘A’: # ‘A’表示定位有效
                continue
                
            # 提取时间、日期、纬度、经度、速度
            # 注意:NMEA中的纬度/经度格式是 “DDMM.MMMMM” 和 “DDDMM.MMMMM”
            lat_str = fields[3] # 纬度字符串,如 “3150.7823”
            lat_dir = fields[4] # 纬度方向,’N’ 或 ‘S’
            lon_str = fields[5] # 经度字符串
            lon_dir = fields[6] # 经度方向,’E’ 或 ‘W’
            speed_knots = float(fields[7]) if fields[7] else 0.0 # 速度,节
            
            # 转换经纬度为十进制度数
            lat = convert_nmea_to_decimal(lat_str, lat_dir)
            lon = convert_nmea_to_decimal(lon_str, lon_dir)
            
            # 速度转换:1节 = 0.514444 米/秒
            speed_ms = speed_knots * 0.514444
            
            data_list.append({
                ‘timestamp’: float(timestamp_str),
                ‘latitude’: lat,
                ‘longitude’: lon,
                ‘speed_knots’: speed_knots,
                ‘speed_ms’: speed_ms
            })
    
    df = pd.DataFrame(data_list)
    # 按时间戳排序
    df.sort_values(‘timestamp’, inplace=True)
    # 计算相对时间(秒),从0开始
    df[‘relative_time_s’] = df[‘timestamp’] - df[‘timestamp’].iloc[0]
    return df

def convert_nmea_to_decimal(nmea_str, direction):
    “”“将NMEA格式的 ‘DDMM.MMMMM’ 转换为十进制度数”“”
    if not nmea_str:
        return None
    deg = int(float(nmea_str) // 100)
    minutes = float(nmea_str) % 100
    decimal = deg + minutes / 60.0
    if direction in [‘S’, ‘W’]:
        decimal = -decimal
    return decimal

使用 pandas 库将数据加载为 DataFrame 是后续所有分析的基础,它提供了强大的数据清洗、转换和计算能力。

4.2 轨迹可视化:在交互式地图上绘制路线

有了经纬度数据,绘制地图是最直观的展示方式。 gmplot 库(Google Maps Plot的封装)或 folium 库(生成Leaflet地图)是很好的选择。这里以 gmplot 为例,需要先申请一个Google Maps API密钥(免费额度通常足够个人使用)。

import gmplot

def plot_route_on_google_maps(df, output_html=‘route.html’, api_key=‘YOUR_API_KEY’):
    “”“使用gmplot在地图上绘制轨迹”“”
    # 取第一个点作为地图中心
    center_lat, center_lon = df[‘latitude’].iloc[0], df[‘longitude’].iloc[0]
    
    # 创建地图对象,设置缩放级别
    gmap = gmplot.GoogleMapPlotter(center_lat, center_lon, zoom=14, apikey=api_key)
    
    # 绘制轨迹线
    gmap.plot(df[‘latitude’].tolist(), df[‘longitude’].tolist(), ‘cornflowerblue’, edge_width=4)
    
    # 标记起点和终点
    gmap.marker(df[‘latitude’].iloc[0], df[‘longitude’].iloc[0], color=‘blue’, title=‘Start’)
    gmap.marker(df[‘latitude’].iloc[-1], df[‘longitude’].iloc[-1], color=‘red’, title=‘End’)
    
    # 生成HTML文件
    gmap.draw(output_html)
    print(f“Route map saved to {output_html}. Open it in a web browser.”)

生成的HTML文件可以在任何浏览器中打开,是一个完全交互式的谷歌地图,可以缩放、平移,清晰地看到你的运动轨迹。

4.3 运动指标计算与图表生成

除了看路线,我们更关心运动表现:速度变化和累计距离。

import numpy as np
import matplotlib.pyplot as plt

def calculate_distance_speed(df):
    “”“计算点与点之间的距离和瞬时速度,并生成图表”“”
    # 使用Haversine公式计算连续两点间的大圆距离(米)
    from math import radians, sin, cos, sqrt, atan2
    
    def haversine(lat1, lon1, lat2, lon2):
        R = 6371000  # 地球半径,单位米
        lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
        dlat = lat2 - lat1
        dlon = lon2 - lon1
        a = sin(dlat/2)**2 + cos(lat1)*cos(lat2)*sin(dlon/2)**2
        c = 2 * atan2(sqrt(a), sqrt(1-a))
        return R * c
    
    distances = [0.0] # 第一个点距离为0
    for i in range(1, len(df)):
        dist = haversine(df[‘latitude’].iloc[i-1], df[‘longitude’].iloc[i-1],
                         df[‘latitude’].iloc[i], df[‘longitude’].iloc[i])
        distances.append(dist)
    
    df[‘segment_distance_m’] = distances
    df[‘cumulative_distance_m’] = df[‘segment_distance_m’].cumsum()
    
    # 计算基于位置变化的瞬时速度(更平滑),也可以直接使用NMEA中的速度
    # 这里我们使用计算出的距离和时间差
    time_diffs = np.diff(df[‘relative_time_s’])
    # 避免除零,计算中间点的速度
    calc_speeds = np.array(distances[1:]) / time_diffs
    # 将速度数组对齐到原始数据点(第一个点速度设为第二个点的速度或0)
    df[‘calc_speed_ms’] = np.concatenate(([calc_speeds[0]], calc_speeds))
    
    # 计算平均速度
    avg_speed = df[‘cumulative_distance_m’].iloc[-1] / df[‘relative_time_s’].iloc[-1] if df[‘relative_time_s’].iloc[-1] > 0 else 0
    
    return df, avg_speed

def plot_speed_distance_charts(df, avg_speed):
    “”“绘制速度-时间和距离-时间图表”“”
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
    
    # 速度-时间图
    ax1.plot(df[‘relative_time_s’], df[‘calc_speed_ms’], ‘g-’, linewidth=1.5, label=‘Instant Speed’)
    # 可以叠加NMEA原始速度进行对比
    # ax1.plot(df[‘relative_time_s’], df[‘speed_ms’], ‘b--’, alpha=0.5, label=‘NMEA Speed’)
    ax1.set_ylabel(‘Speed (m/s)’)
    ax1.set_title(f‘Speed vs. Time (Avg: {avg_speed:.2f} m/s)‘)
    ax1.grid(True, alpha=0.3)
    ax1.legend()
    
    # 距离-时间图
    ax2.plot(df[‘relative_time_s’], df[‘cumulative_distance_m’], ‘b-’, linewidth=2)
    ax2.set_xlabel(‘Time (seconds)’)
    ax2.set_ylabel(‘Cumulative Distance (m)’)
    ax2.set_title(f‘Total Distance: {df[“cumulative_distance_m”].iloc[-1]:.2f} meters’)
    ax2.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.savefig(‘speed_distance_plot.png’, dpi=150)
    plt.show()

通过 matplotlib 生成的专业图表,可以清晰地看到整个行程中速度的波动(如上坡减速、下坡加速)以及距离的平稳累积,为运动分析提供了量化依据。

5. 系统集成、优化与常见问题排查

将硬件和软件两部分结合起来,并处理实际应用中会遇到的问题,才是一个完整的项目。

5.1 端到端操作流程

  1. 硬件准备与固件烧录 :按照前述连接图组装好硬件,使用USB线连接电脑和ESP32,通过Thonny或uPyCraft将完整的MicroPython程序(包含SD卡初始化、A9G控制、主循环)烧录到ESP32中。
  2. 户外数据采集 :将设备放入防水盒或固定在自行车上,确保GPS天线面朝天空。上电启动,设备上的LED(如果有)或通过串口监视器应能指示GPS正在搜星和记录。开始你的运动。
  3. 数据导出 :运动结束后,关闭设备电源。取出SD卡,通过读卡器插入电脑。你会看到一个或多个 trace_xxxxxx.txt 文件。
  4. 数据分析 :在电脑上运行我们编写的PC端Python脚本。修改脚本开头的文件路径指向你的 trace.txt 文件,然后运行。脚本会自动解析数据,生成 route.html 地图文件和 speed_distance_plot.png 图表文件。

5.2 性能优化与功能扩展思考

  • 功耗优化 :对于长时间记录,功耗是关键。可以修改ESP32固件,使其在两次GPS数据记录之间进入 light-sleep deep-sleep 模式,仅由A9G模块的定时器或运动传感器唤醒。A9G模块本身也可以通过AT指令进入低功耗模式。
  • 数据过滤与压缩 :在静止或低速移动时,可以降低记录频率(如每10秒记录一次),或者仅当位置变化超过一定阈值时才记录,以节省存储空间。
  • 实时数据传输 :利用A9G的GPRS功能,可以将轨迹数据通过MQTT或HTTP协议实时上传到云服务器(如阿里云、腾讯云IoT平台),实现远程追踪。
  • 增加传感器 :通过ESP32的I2C或ADC接口,可以连接心率传感器、气压计(测海拔)、陀螺仪等,丰富数据维度。
  • 本地显示 :可以为ESP32连接一个小型OLED屏幕,实时显示当前速度、距离、时间等基本信息。

5.3 常见问题与排查技巧实录

在实际制作和调试过程中,你几乎一定会遇到下面这些问题。这里是我的排查笔记:

问题1:ESP32无法连接电脑串口或无法烧录固件。

  • 排查 :首先检查USB线是否支持数据传输(有些线只能充电)。在设备管理器中查看端口是否出现,尝试更换USB口。按住ESP32板上的 BOOT 按钮再点击 EN 复位按钮进入下载模式,然后尝试烧录。
  • 技巧 :使用 CP210x CH340 这类通用USB转串口芯片的ESP32开发板,驱动兼容性更好。

问题2:A9G模块上电后无反应,AT指令无回复。

  • 排查
    1. 用万用表测量A9G模块的供电电压是否稳定在3.8V-4.2V之间。
    2. 用逻辑分析仪或另一个串口调试助手监听ESP32发送给A9G的AT指令 AT\r\n ,确认指令格式正确(有回车换行)。
    3. 检查 PWR_KEY RESET 引脚的启动时序是否符合数据手册要求,高电平脉冲时间是否足够(通常需要1秒以上)。
    4. A9G模块的天线是否连接牢固?在户外开阔地测试。

问题3:GPS定位时间长或定位无效(NMEA语句状态为‘V’)。

  • 排查
    1. 环境 :确保在室外开阔天空下测试,远离高楼和室内。首次冷启动可能需要几分钟。
    2. 天线 :检查GPS有源天线的连接,并确保其贴片面朝上。
    3. 指令 :确认发送了 AT+GPS=1 AT+GPSRD=1 指令。
    4. 供电 :GPS模块搜星时瞬时电流可能较大,确保电源能提供足够电流(>100mA)。

问题4:SD卡无法挂载或文件写入失败。

  • 排查
    1. 确认SD卡格式化为FAT32格式(不是exFAT或NTFS)。
    2. 检查SPI引脚连接是否正确,特别是片选 CS 引脚。
    3. 尝试降低SPI波特率(如从 1000000 降到 400000 ),某些SD卡模块或接线质量一般时,高速率不稳定。
    4. 在写入文件时,务必使用 with open() as f: 上下文管理器,或者手动执行 f.close() f.flush() ,确保数据从缓存写入卡内。

问题5:PC端Python脚本运行报错,提示缺少库。

  • 排查 :这是最常见的问题。在命令行中,使用 pip install pandas matplotlib gmplot pyserial (根据你的脚本实际引用的库)逐一安装。如果使用 gmplot ,还需要单独安装 googlemaps 库并配置API密钥。建议使用虚拟环境( venv )来管理项目依赖。

问题6:生成的地图空白或位置严重偏移。

  • 排查
    1. 检查 gmplot 使用的Google Maps API密钥是否有效,且已启用了“Maps JavaScript API”。
    2. 检查经纬度数据解析是否正确。NMEA格式是“度分”(DDMM.MMMM),需要转换成十进制度(DD.DDDD)。如果转换公式错误,位置会偏差上百公里。
    3. 将解析后的经纬度数据先打印出来,用谷歌地图的“坐标搜索”功能验证一下是否正确。

这个项目从构思到实现,最耗时的部分往往不是写代码,而是硬件调试和环境搭建。一旦打通了“硬件采集-存储-软件解析-可视化”这个完整链路,你会发现基于位置数据的创新应用思路会源源不断地涌现。它不仅仅是一个骑行记录器,更是一个通用的地理信息数据采集终端原型。你可以用它记录徒步路线、记录无人机航点、甚至结合传感器做简单的环境地理标记。希望这个详细的拆解能帮你少走弯路,顺利做出属于自己的轨迹记录系统。

更多推荐