经验技巧

技术宅逆天!自制GPS码表(图文)

  译者注:某天小编在各大论坛闲逛的时候,看见了一个帖子问大家,在使用什么码表?小编本想略过,一看,不得了。这位爱骑车的技术宅不堪GPS码表的高价,自制出了一套GPS码表系统,之所以说是系统,因为它还能连接上电脑实现数据同步分析等的强大功能,如果你也懂点编程,值得一看!

   自从19世纪中期自行车运动从欧洲、北美发源以来,吸引了世界上一批又一批的爱好者参与其中。在曾经被称为“自行车王国”的中国,上世纪80年代,自行车也作为曾经的“四大件”之一走入千家万户,在那时,自行车成为了民众主要的交通工具。随着社会经济的发展,汽车逐步走入寻常家庭,自行车也一度淡出了人们的生活。不过近些年来,随着“绿色低碳”的生活理念渐入人心,自行车运动开始展现出了自己独特的魅力,使其又成为了一项时尚的健身运动,越来越多的人参与到了其中。

   自从我参加了骑行运动之后,便被其“挑战极限,积极向前”的魅力所深深吸引,深陷其中不可自拔。业余折腾电子数码的时间也慢慢转向了自行车运动。在参加一些骑行活动的过程中,常常会想记录一下自己的骑行路线、骑行数据,事后可以进行分析,作为训练数据也能使自己得到提高。在一番寻找后发现智能手机上有提供这样功能的例如Endomondo应用供爱好者免费使用。虽然智能手机现在已经非常普遍,但是智能手机的续航力以及国外应用与国内用户的使用习惯差异都是不小的问题。再加上自行车运动存在一定的危险性以及需要适应不同的气候,一旦摔车,损坏智能手机的成本就会显得比较高。因此我就想到了可以利用Arduino来做一个低成本专用的自行车车载电脑来记录并实时显示骑行数据,并在训练完成后使用电脑针对记录的数据进行分析,以得到想要的结果和报表。

   在应用设计初期,就把这款应用分成了两大部分来进行设计,第一部分是基于arduino的硬件,体积小,可以安装在自行车的把横上,负责收集和记录骑行数据,并通过LCD显示屏实时显示时速等信息。第二部分则是分析统计的系统,由于arduino的SRAM和频率的限制,不太适合做数据的分析,因此我把这部分功能拆分开来,设计成由计算机系统来完成——arduino记录的数据上传到计算机系统上后进行分析并绘制图表。第二部分的系统,在后期设计中我设计成了一个Web 2.0的应用。这样就可以方便的将统计的结果进行分享,可以在任何地方给任何你想分享的伙伴分享你的训练数据、骑行路线。

   在我设计并实现的原型产品中,基于arduino的硬件部分,主要由如下几个模块来构成:

  • arduino主控板,行车电脑的核心

  • 电源模块,为所有硬件提供电源

  • GPS模块,提供GPS定位信息,以得到位置数据、速度数据、高度数据

  • LCD模块,实时显示骑行数据

  • SD/TF卡存储模块,储存骑行数据

   在未来还可能会加上如下模块来进一步完善功能:

  • 红外或磁感应模块,进行踏频统计

  • 无线心率探测模块,心率数据统计

system_arch
基于arduino的gTracking系统架构简图

在实际制作的过程中,由于对体积有小型化的要求,我选用了如下的硬件:

  • Arduino pro mini, 省去了RS232 TTL转USB部分的电路,体积进一步缩小,ATMega328P也能保证有足够的Flash和SRAM。

  • 3.7v转5v升压充电一体模块,去除了USB母口,缩小体积。

  • UC-915GPS模块,使用U-Blox 6010芯片,带内置天线,3.5cmx1.6cmx0.75cm超小体积。

  • Nokia 5110显示屏,84x48分辨率,够用,便宜,成本低,体积小。

  • 自制TF存储模块,体积超小,带3.3V电源转换

   TF卡是工作在3.3v的电压下的,由于Arduino pro mini上没有3.3v的电压输出,于是,在自制的TFT模块上,使用了AMS1117-3.3来将5v电源转成3.3v,同时这个3.3v的输出也为LCD模块提供了电源输入。Arduino的SPI IO端口输入输出都是5V的TTL电平,因此需要一个level shifter来将5V的电平信号转化成3.3v的以供TF卡使用。在早期的设计中,我使用了74LVC245来做Level shifter,但是由于需要尽量减小体积,即使SSOP封装的74LVC245也会显得较占空间。考虑到负载电路并不复杂,于是在这里就用了简单的分压电路,使用1.8K和3.3K的贴片电阻实现了电平电压转换的功能。

TFBoard
TF卡模块PCB覆铜板用热转印草图

   由于Nokia 5110显示屏背面没有任何电子元件,于是我将包括arduino pro mini、SD模块、GPS模块都用双面胶固定在了LCD显示屏的背面,整体的厚度可以做到小于1cm。这样就完美的实现了缩小体积的目标。

breadboard_test
面包板测试
board_front
将组件粘贴到LCD背面后的LCD模块
board_rear
LCD背面粘贴的arduino和GPS、TF模块

   电池部分选择了3.7V锂聚合物电池,这样就能把身材做的很小。不过由于我只是做一个原型产品,所以用了手上现成的4200mAh的电池,体积显得略大了些。

   准备好了硬件的部分后,就需要做连线焊接的工作了,下面是该应用设备使用的Arduino端口的规划表。

PIN 0 (RX)GPS模块TXPIN 1 (TX)GPS模块RXPIN 2PIN 3Nokia 5110 LCD模块 SCKPIN 4Nokia 5110 LCD模块 MOSIPIN 5Nokia 5110 LCD模块 A0PIN 6Nokia 5110 LCD模块 ResetPIN 7PIN 8PIN 9PIN 10TF卡模块 片选SSPIN 11TF卡模块 MOSIPIN 12TF卡模块 MISOPIN 13TF卡模块 SCK

   在这里,使用硬件Serial来作为GPS NMEA信号输入而不使用SoftSerial的好处是:

  1. 避免SoftSerial的兼容问题;

  2. 节省Flash的空间,减少SRAM使用。

   而需要注意的是,Nokia 5110 LCD模块使用的是非标准的SPI通信协议,因此不能使用硬件SPI,而需要使用Soft SPI来驱动。

   连接完成后就开始代码的编写工作了。在这个项目中,GPS模块的驱动使用了TinyGPS库,LCD显示则使用了u8glib,TF卡模块驱动使用了SD库。当然,为了节省SRAM,对库也进行了修改。例如对TinyGPS的cardinal函数进行了修改,将数组使用PROGMEM进行存储,节省SRAM的空间。

   整个系统的代码逻辑其实很简单。初始化完成后,每秒检查一次GPS信号,如果信号正常则更新信息并在LCD屏幕更新显示的实时数据。由于事后用于分析的数据不需要精确到每秒这样的级别,因此设定每5秒判断一次,如果当前位置和5秒前相比发生了一定的位移量则将数据记录到TF卡,以供分析。

   为了简化数据存储的方式,数据以类似CSV的格式存储在TF卡上,文件名则为开始记录的日期,每一段数据以数据格式的版本号开始,每一行都是一笔数据。格式如下:

   日期,时间,连接的卫星个数,纬度,经度,海拔高度,时速,行驶方向,

flowchart
流程图

   下面是目前处于beta测试阶段的主程序的源代码,以供参考。

C代码:

  1. #include "U8glib.h"
  2. #include <SD.h>
  3. #include <TinyGPS.h>
  4. #include <avr/pgmspace.h>
  5. //使用PROGMEM存放GPS方向数组,节省SRAM
  6.  prog_char d_0[] PROGMEM = "N";
  7.  prog_char d_1[] PROGMEM = "NNE";
  8.  prog_char d_2[] PROGMEM = "NE";
  9.  prog_char d_3[] PROGMEM = "ENE";
  10.  prog_char d_4[] PROGMEM = "E";
  11.  prog_char d_5[] PROGMEM = "ESE";
  12.  prog_char d_6[] PROGMEM = "SE";
  13.  prog_char d_7[] PROGMEM = "SSE";
  14.  prog_char d_8[] PROGMEM = "S";
  15.  prog_char d_9[] PROGMEM = "SSW";
  16.  prog_char d_10[] PROGMEM = "SW";
  17.  prog_char d_11[] PROGMEM = "WSW";
  18.  prog_char d_12[] PROGMEM = "W";
  19.  prog_char d_13[] PROGMEM = "WNW";
  20.  prog_char d_14[] PROGMEM = "NW";
  21.  prog_char d_15[] PROGMEM = "NNW";
  22.  PROGMEM const char *dir_table[] =
  23.  {  
  24.    d_0,
  25.    d_1,
  26.    d_2,
  27.    d_3,
  28.    d_4,
  29.    d_5,
  30.    d_6,
  31.    d_7,
  32.    d_8,
  33.    d_9,
  34.    d_10,
  35.    d_11,
  36.    d_12,
  37.    d_13,
  38.    d_14,
  39.    d_15
  40.  };
  41. TinyGPS gps;
  42. U8GLIB_PCD8544 u8g(3, 4, 99, 5, 6);  // SPI Com: SCK = 3, MOSI = 4, CS = 永远接地, A0 = 5, Reset = 6
  43. File myFile;
  44. boolean sderror = false;                //TF卡状态
  45. char logname[13];                            //记录文件名
  46. boolean writelog = true;                //是否要记录当前数据到TF卡标志
  47. boolean refresh = false;                //是否要更新液晶显示标志
  48. boolean finish_init = false;    //初始化完成标志
  49. byte satnum=0;          //连接上的卫星个数
  50. float flat, flon, spd, alt, oflat, oflon;       //GPS信息,经纬度、速度、高度、上一次的经纬度
  51. unsigned long age;                        //GPS信息 fix age
  52. int year;                                          //GPS信息 年
  53. byte month, day, hour, minute, second, hundredths;            //GPS信息 时间信息
  54. char crs[4];                    //GPS信息 行驶方向
  55. char sz[10];                    //文本信息
  56. byte cnt=0;                              //循环计数器
  57. static void gpsdump(TinyGPS &gps);
  58. static bool feedgps();
  59. static void print_date(TinyGPS &gps);
  60. static void print_satnum(TinyGPS &gps);
  61. static void print_pos(TinyGPS &gps);
  62. static void print_alt(TinyGPS &gps);
  63. static void print_speed(TinyGPS &gps);
  64. static void print_course(TinyGPS &gps);
  65. static String float2str(float val, byte len);
  66. //AVR定时器,每秒触发
  67. ISR(TIMER1_OVF_vect) {
  68.  TCNT1=0x0BDC; // set initial value to remove time error (16bit counter register)
  69.  if (finish_init) refresh = true;      //在完成初始化后,将刷新显示标志设为true
  70. }
  71. void setup()
  72. {
  73.  finish_init = false;
  74.  //设置并激活AVR计时器
  75.  TIMSK1=0x01; // 启用全局计时器中断
  76.  TCCR1A = 0x00; //normal operation page 148 (mode0);
  77.  TCNT1=0x0BDC; //set initial value to remove time error (16bit counter register)
  78.  TCCR1B = 0x04; //启动计时器
  79.  pinMode(10, OUTPUT);
  80.  oflat = 0;
  81.  oflon = 0;
  82.  logname[0]=' ';
  83.  Serial.begin(9600); //GPS模块默认输出9600bps的NMEA信号
  84.  u8g.setColorIndex(1);         // 设置LCD显示模式,黑白
  85.  u8g.setFont(u8g_font_04b_03br);       //字体
  86.  //TF卡的片选端口是10
  87.  if (!SD.begin(10)) {
  88.    sderror = true;
  89.  }
  90.  //初始化完成
  91.  finish_init = true;
  92. }
  93. void loop()
  94. {
  95.   //读取并分析GPS数据
  96.   feedgps();
  97.   //刷新显示
  98.   if (refresh)
  99.   {
  100.      cnt %=10;
  101.      writelog = true;
  102.  
  103.      u8g.firstPage();
  104.      do{
  105.        gpsdump(gps);
  106.        u8g.setPrintPos(70,48);
  107.        u8g.print( cnt);  
  108.      } while ( u8g.nextPage() );  
  109.    
  110.          //每5秒且GPS信号正常时将数据记录到TF卡
  111.      if (cnt % 5 == 0 && writelog)
  112.      {
  113.        logEvent();
  114.      }
  115.          //刷新完毕,更新秒计数器
  116.      refresh = false;
  117.      cnt++;
  118.   }
  119. }
  120. static void gpsdump(TinyGPS &gps)
  121. {
  122.  print_satnum(gps);
  123.  print_date(gps);
  124.  print_pos(gps);
  125.  print_speed(gps);
  126.  print_alt(gps);
  127.  print_course(gps);
  128. }
  129. //更新并显示卫星个数
  130. static void print_satnum(TinyGPS &gps)
  131. {
  132.  satnum = gps.satellites();
  133.  if ( satnum != TinyGPS::GPS_INVALID_SATELLITES){
  134.    u8g.setPrintPos( 46, 6);
  135.    u8g.print(satnum);
  136.    writelog &= true;
  137.  }
  138.  else {
  139.    u8g.drawStr( 10, 15, F("gTracking System"));
  140.    u8g.drawStr( 22, 23, F("build 2506"));
  141.    u8g.drawStr( 0, 34, (cnt % 2) ? F("Searching...") : F("            "));
  142.    //u8g.drawStr( 7, 48, F("wells.osall.com"));
  143.    writelog = false;
  144.  }
  145.  feedgps();
  146. }
  147. //更新并显示GPS经纬度信息
  148. static void print_pos(TinyGPS &gps)
  149. {
  150.  gps.f_get_position(&flat, &flon, &age);
  151.  if (flat != TinyGPS::GPS_INVALID_F_ANGLE && flon !=  TinyGPS::GPS_INVALID_F_ANGLE) {
  152.    u8g.setPrintPos(0,40);
  153.    u8g.print(float2str(flon,8));
  154.    u8g.print(F(" : "));
  155.    u8g.print(float2str(flat,8));
  156.    writelog &= true;
  157.  }
  158.  else
  159.    writelog = false;
  160.  feedgps();
  161. }
  162. //更新并显示GPS高度信息
  163. static void print_alt(TinyGPS &gps)
  164. {
  165.  alt = gps.f_altitude();
  166.  if (alt != TinyGPS::GPS_INVALID_F_ALTITUDE){
  167.    u8g.setPrintPos(0,48);
  168.    u8g.print(F("Alt: "));
  169.    u8g.print(float2str(alt,5));
  170.    writelog &= true;
  171.  }
  172.  else
  173.  {
  174.    writelog = false;
  175.  }
  176.  feedgps();
  177. }
  178. //更新并显示行驶方向
  179. static void print_course(TinyGPS &gps)
  180. {
  181.  if (gps.f_course() == TinyGPS::GPS_INVALID_F_ANGLE)
  182.    writelog = false;
  183.  else
  184.  {
  185.        //从PROGMEM中读取数组中的字符串
  186.    strcpy_P(crs,(char*)pgm_read_word(&(dir_table[gps.cardinal(gps.f_course())])));
  187.    u8g.setPrintPos(60,6);
  188.    u8g.print(crs);
  189.  }
  190.  feedgps();
  191. }
  192. //更新并显示GPS时间
  193. static void print_date(TinyGPS &gps)
  194. {
  195.  gps.crack_datetime(&year, &month, &day, &hour, &minute, &second, &hundredths, &age);
  196.  if (age != TinyGPS::GPS_INVALID_AGE && month>0 && day>0)
  197.  {
  198.    u8g.setPrintPos( 0, 6);
  199.    sprintf(sz, "%02d",((hour+8) % 24)); //显示北京时间 GMT+8
  200.    u8g.print(sz);
  201.    u8g.setPrintPos( 11, 6);
  202.    u8g.print(second % 2 ? F(":") : F(" "));
  203.    u8g.setPrintPos(15,6);
  204.    sprintf(sz, "%02d",minute);
  205.    u8g.print(sz);
  206.    writelog &= true;
  207.  }
  208.  else
  209.    writelog = false;
  210.  feedgps();
  211. }
  212. //更新并显示时速
  213. static void print_speed(TinyGPS &gps)
  214. {
  215.  spd = gps.f_speed_kmph();
  216.  if (spd != TinyGPS::GPS_INVALID_F_SPEED)
  217.  {
  218.        if(spd<0.5) spd=0.0; //屏蔽0时速时的误差显示
  219.    u8g.drawStr(0,14, F("SPEED"));
  220.    u8g.setPrintPos(0,32);
  221.    u8g.setFont(u8g_font_fub14n); //使用大字体
  222.    u8g.print( float2str(spd,5));
  223.    u8g.setFont(u8g_font_04b_03br);
  224.    u8g.print(F(" km/h"));
  225.    writelog &= true;
  226.  }
  227.  else
  228.    writelog = false;
  229.  feedgps();
  230. }
  231. //读取并解码GPS信息
  232. static bool feedgps()
  233. {
  234.  while (Serial.available())
  235.  {
  236.    if (gps.encode(Serial.read()))
  237.      return true;
  238.  }
  239.  return false;
  240. }
  241. //将浮点数转化为字符串(整数部分<1000)
  242. static String float2str(float val, byte len)
  243. {
  244.  String str = "";
  245.  char tmp[4];
  246.  byte pos = 0;
  247.  int p1;
  248.  bool minus=false;
  249.  //取绝对值
  250.  if (val<0)
  251.  {
  252.    minus=true;
  253.    len--;
  254.    val = abs(val);
  255.  }
  256.  p1=(int)val;  //取整数部分
  257.  val=val-p1;   //得到小数部分
  258.  itoa(p1,tmp,10);      //整数部分转化为字符串
  259.  str.concat(tmp);
  260.  //获得小数点位置
  261.  if (p1 == 0) {
  262.    pos = 1;
  263.  }
  264.  else {
  265.    for (pos=0;pos<len && p1>0;pos++)
  266.      p1=p1/10;
  267.  }
  268.  //小数点
  269.  if (pos<len && val>0){
  270.    pos++;
  271.    str.concat('.');
  272.  }
  273.  //小数部分加入字符串
  274.  for (;pos<len&& val>0;pos++)
  275.  {
  276.    str.concat((char)('0'+ (byte)(val*10)));
  277.    val= val * 10 - ((byte)(val*10));
  278.  }
  279.  if (minus) str = "-" + str;
  280.  return str;
  281. }
  282. //记录数据到TF卡
  283. void logEvent()
  284. {
  285.  if (logname[0]==' ') {        //获取文件名
  286.    sprintf(logname, "%04d%02d%02d.trc",  year, month, day, hour, minute, second);
  287.    myFile=SD.open(logname, FILE_WRITE);
  288.    if (!myFile) {
  289.      sderror = true;
  290.    }
  291.    else {
  292.       myFile.println(F("#gTracking#b2506#"));  //输出数据的版本信息
  293.       myFile.close();
  294.       delay(10);
  295.    }
  296.  }
  297.  //当位置和上一次(5秒前)相比发生一定变化量时才记录数据(节省数据文件的空间)
  298.  if (writelog && logname[0]!=' ' &&  (abs(flat-oflat) > 0.0001 || abs(flon - oflon) > 0.0001))
  299.  {
  300.    myFile=SD.open(logname, FILE_WRITE);
  301.    if (!myFile) {
  302.      sderror = true;
  303.    }
  304.    else
  305.    {
  306.      oflat = flat;
  307.      oflon = flon;
  308.      sprintf(sz, "%04d-%02d-%02d",  year, month, day);
  309.      myFile.print(sz);
  310.      myFile.print(F(","));
  311.      sprintf(sz, "%02d:%02d:%02d,", hour, minute, second);
  312.      myFile.print(sz);
  313.      sprintf(sz,"%02d,",satnum);
  314.      myFile.print(sz);
  315.      myFile.print(float2str(flat,20));
  316.      myFile.print(",");
  317.      myFile.print(float2str(flon,20));
  318.      myFile.print(",");
  319.      myFile.print(float2str(alt,10));
  320.      myFile.print(",");
  321.      myFile.print(float2str(spd,10));
  322.      myFile.print(",");
  323.      myFile.print(crs);
  324.      myFile.println(",");
  325.      delay(20);
  326.      myFile.flush();
  327.      delay(50);
  328.      myFile.close();
  329.    
  330.    }
  331.  }
  332. }

   将代码写入arduino之后,就能够开机测试了。由于GPS模块使用硬件Serial端口,因此烧录代码的时候要注意先要把GPS模块的TX/RX信号线断开,才能正常烧录代码,烧录完成后再连接上即可。

   最终将电路板和电池组件连接完成,并在LCD模块上引出一个控制背光LED的开关,以便于在夜晚使用。装入大小合适的外壳,则成为完成的原型产品。

以下是我做的原型产品的照片,和测试时的录像。

device
设备外观
display
屏显内容
internal
内部结构
internal2
内部电路板

   完成了Arduino终端设备之后,就要实现该应用的第二部分数据分析的Web应用了。

   Web应用的部分,我使用了CodeIgnite作为PHP Framework来快速搭建。骑行轨迹显示的部分则结合Google MAP API来实现,骑行数据(例如时速、高度曲线等)则使用SVG矢量图形来表现。

   基本上整个Web应用的思路是这样的:

注册用户登录后,通过浏览器,在上传页面上传记录的数据文件,并输入一些描述信息。数据上传到服务器后,服务端代码会对数据进行分析,并生成谷歌地图所使用的轨迹KML文件,骑行数据分析后的时速、高度曲线图的SVG文件,并把这些信息都存放入数据库。用户还能设置是否公开这些信息,以便于其他用户或所有人查看。用户还能在查询汇总界面下载到包含轨迹的KML文件,以便于在谷歌地图等软件中使用。

web1
Web登录界面
web2
上传记录的数据文件
web3
分析数据并补充轨迹简介
web4_1
分析结果,显示骑行轨迹
web4_2
骑行时速曲线,点击任何一个记录点可在地图上显示该记录点的地理位置,以便于分析训练成绩
web4_3
海拔高度曲线和卫星信号强度曲线,同样可点击数据点
web5
检索轨迹页面

   由于这一部分的代码量较多,虽然大部分功能已经完成,但包括与好友分享路线等功能还未完全完成,因此就不在这里一一说明,如有需要我可以进行单独分享,当代码基本完全完成后,将会作为开源项目进行分享。

   下面是我分享的几个测试路线的数据分析结果(最近Google Map常被骚扰,如果遇到网页打开白画面或者页面无法正常显示地图,则是万恶的GFW的功劳,请自行准备过墙梯):

责任编辑:Cash

上一篇:让室内训练告别枯燥(图文)

下一篇:返回列表

大家都说

您需要登录后才可以回复 登录  |   注册

您还可以输入200
  • 热门评论
  • huoshan017 2015-06-02 15:59

    这个是C的代码吗?明明用的是C++的库

    +1

    22
    回复
    举报
举报成功,管理员会尽快核实及处理
选择举报类型
安全提示

根据《网络安全法》规定,账号需要绑定手机号才可以使

用评论、发帖、打赏。

请及时绑定,以保证产品功能顺畅使用。