Vehicle GPS Logger and Speedometer

Updated 23 June 2023

Purpose

To display time and speed to a driver while logging timestamped raw latitude, longitude and speed as returned by the GPS satellite network. This allows a driver to double-check their speedometer reading and provides an audit trail that can be used to verify the accuracy of the information that a Traffic Infraction notice is based on. This log can then be potentially used as evidence should the driver wish to challenge Traffic Infraction notices based on inaccurate information before a Magistrate.

Background

 

My daughter has a fairly old hatchback that does not go too fast. Despite this, she has had an inordinate number of speeding tickets from one particular location where a particular QPS van presumably containing a speed camera. I gave her the benefit of the doubt and started working on this to double-check her speed.

Along with logging GPS-measured speed, I also logged raw location data so I could double-check the speed using the Haversine formula for measuring distance on a sphere.

When she received her next ticket, we checked the log and double-checked it by manually calculating the speed in Excel. Our examination of the data led us to the conclusion that the speed camera was out by a value between 10 and 11km/h.

Taking our log and calculations, we successfully challenged the ticket with the Magistrate ordering the camera that identified my daughter’s vehicle to be recalibrated.

Skill Level

I have written this tutorial for those who would describe themselves as inexperienced with the Arduino and C++ languages specifically. However, those with a passing understanding of concepts such as conditionals, loops and formal logic structures will find it easier to follow along.

Also, I have comprehensively commented each sketch, function and snippet of the codebase we will be constructing in the course of this tutorial.

I am aware this will be far too slow for readers at a higher level of familiarity, so those readers are encouraged to skip ahead, using the section headings as a guide.

Parts List

Most of these parts are available from Core Electronics. I got the SD card and double-sided tape at Officeworks for about $15 or so and I picked up a magnetic phone mount from one of those mobile phone kiosks at the shopping centre near me for $10 or so.

Assembly

  1. Solder female headers into place on the Feather Tripler board.
  2. Solder male headers into place on the Adalogger board as well the Ultimate GPS and 128x32 OLED display Featherwings.
  3. GND and Vcc are connected throughout the tripler board, but Tx/Rx on the GPS needs to be wired to Rx/Tx on the Feather and SCL and SDA on the display needs to be wired through to SCL and SDA on the display. Based on where you will mount each Feather component solder the four data wires to match.
  4. Plug in Feather board and Featherwings (I prefer GPS at the top, display in the middle and Adalogger at the bottom) into the female headers we have soldered onto the tripler.
  5. Using the double-sided tape, affix the non-adhesive side of the thin metal plate in the magnetic phone/ vent mount to the rear of the Feather assembly
  6. Affix the phone holder to the vent of the vehicle in such a way a comfortable viewing angle is achieved and attach the Feather assembly with the taped metal plate to the holder.
  7. Insert the cigarette lighter adapter and connect to the Feather assembly with the micro USB cable.

As we can see, the hardware component of this project is fairly straightforward - it requires soldering some headers onto a microcontroller, a GPS module and a display module before plugging a bunch of stuff into each other.

Software Environment

Arduino IDE Preparation


If you don’t already have the Arduino IDE, it can be downloaded from the Arduino software page located at: https://www.arduino.cc/en/software

We will start up the IDE and check we have board support for the Adafruit m0 Adalogger as well as the required libraries for our sketch.

Microcontroller Board Support


To check we have board support and open the Board Manager tab at the left. We need two board packages, the Adafruit SAMD Boards(32-bits ARM Cortex-M0+) package and the Arduino SAMD Boards(32-bits ARM Cortex-M0+) package.

If you cannot find the Adafruit package, open File>Preferences menu and scroll down to the Additional Boards Manager URLs text field and check it is populated with the following URL: https://adafruit.github.io/arduino-board-index/package_adafruit_index.json

If the text field is empty, add the above address and restart the IDE.

Required Libraries


We can get our required libraries by opening the library sidebar and searching for the manufacturer and name of our modules. The libraries we will install are:

  • Adafruit GPS Library by Adafruit
  • Adafruit SSD1306 by Adafruit

We will use the in-built Arduino library for operating the Micro SD card module.

Writing Our Sketch

Now we move on to writing our sketch. We start off by doing a diagnostic check of each module, writing the code that applies to that module only before combining our module code together to write our master sketch that takes GPS data, modifies and/or formats it as required before displaying the time and speed while also logging this information along with locational data for later use.

GPS Module-Specific Code

Diagnostic Check of the Module

We start by writing a skeleton sketch that is sufficient only to start the display and verify we have included module-specific library dependencies, we have correctly declared the GPS class to operate the module and can successfully instantiate it.

If there are no errors thrown, we also include just enough code to verify it is working. In this case, it is the code contained in the main loop() function.

#include <Adafruit_GPS.h>
//  Here we create a GPS class called GPS which will operate on the
//  internal Serial1 connection
Adafruit_GPS GPS(&Serial1);

//  Set aside 32 bits for an unsigned (positive only) variable to measure
//  time. The equivalent declaration to 'uint32_t' is 'unsigned long'.
//  However I prefer to use this format as it gives far more information
//  about the variable. It breaks down as:
//  u(unsigned)int(integer)32(32 bits)_t(type).
//  
//  The millis function is an internal function that returns the number of //  milliseconds since board start up when called. By declaring the timer
//  variable equal to millis() and then subtracting the timer variable from
//  the millis() call later on, we can get an interval of time between the
//  equality and subtraction.
uint32_t timer = millis();

void setup(){
//  The baud rate of the computer facing serial is set differently to the
//  internal baud rate to prevent any strangeness occurring.
 Serial.begin(115200);
//  The GPS class is instantiated at a baud rate of 9600 bits per second
 GPS.begin(9600);
//  We set the GPS to return output in the form of NMEA sentences. These
//  can be googled for further information.
 GPS.sendCommand(PMTK_SET_NMEA_OUTPUT_RMCGGA);
//  And an output rate of 5 Hertz or 5 sentences per second is set.
//  Adafruit recommends only a 1 Hz query rate, but I find that a bit slow
//  for vehicle logging so have increased it. While I have had no problems
//  doing this, if you encounter any issues, it may be worthwhile bumping
//  this back down by substituting PMTK_SET_NMEA_UPDATE_1HZ into the
//  parentheses below.
 GPS.sendCommand(PMTK_SET_NMEA_UPDATE_5HZ);
}

void loop(){
//  We request an NMEA sentence from the GPS module. We don't use the raw
//  sentence anywhere else, so we declare a local variable ‘c’ and write it
//  there
 char c = GPS.read();
//  The below is a compound statement of two conditionals and a function.
//  Expanding it, we would get something similar to the following:
//                    if(GPS.newNMEAreceived()){
//                      if(!GPS.parse(GPS.lastNMEA())){
//                        return;
//                      }
//                    }
//
//  The logic of the statement is as follows:
//         - Have we got a new NMEA sentence from the GPS.read() function?
//         - If so, but it was not parsed into the GPS class, start the
//           loop over.
//         - Otherwise continue.
 if(GPS.newNMEAreceived()) if(!GPS.parse(GPS.lastNMEA())) return;
//  Here, we are checking that at least 0.5 or 500 milliseconds have passed
//  before checking for a GPS fix
 if (millis() - timer > 750){
//  The below line resets the timer every 750+ milliseconds
   timer = millis();
//  Now we are checking if the GPS module can get a stable signal for a fix
//  on location and other data
   if (GPS.fix) {
     Serial.println("GPS fixed. Module working!");
//  while(true) is halting code. A while() loop operates for as long as its
//  contents within the parentheses are true. As in this case it is
//  declared as always true, the loop does not stop, effective halting the
//  code here.
     while(true);
//  If we see the message "GPS fixed. Module working!" in the serial port,
//  we can be confident our code is working and the GPS module can get a
//  satellite fix.
   }      
   else{
//  The below is a crude animation to be printed to the serial connection
//  so a user may be sure the loop is still running and has not hung or
//  been halted.
     Serial.println("-No GPS Fix-");
     delay(100);
     Serial.println("|No GPS Fix|");
     delay(100);
   }
 }
}

Running this code, we do indeed see the “GPS fixed. Module working!” message. So we know that our GPS module works and that we have successfully written the required code to ensure it functions.

Start Constructing Data Strings

We can now modify this first sketch to include a user-defined function that will take variables from the GPS module to construct data strings we will require later for display and logging purposes.

Note, the sketch is still somewhat small, but I will start truncating the parts irrelevant to the task we are undertaking. These truncations will be indicated with an ellipsis at the location they have been removed from. This is to remove any confusion or fatigue from seeing the same irrelevant code repeatedly.

…
//  Below our class declaration(s) we declare our global variables, in this
//  case, 4 strings which will be populated with the date,24-hour time,
//  12-hour time and the 12-hour time suffix respectively.
//
//  Ordinarily, if I was writing this for myself, I would not bother with
//  a 12 hour time string, but my daughter, her friends and my father
//  are adamant in their dubious claim that it is easier to read.
String dateString;
String logTime;
String displayTime;
String amPM;

//  We also introduce 5 new global 8 bit integer variables below to record
//  our time zone and to allow us to adjust to our local time from the UTC
//  time provided by the GPS network.
//
//  There are libraries for this, but it is not particularly complex, nor
//  is it computationally expensive to do this ourselves.
uint8_t timeZone = 10;
uint8_t adjHour;
uint8_t adjDay;
uint8_t adjMonth;
uint8_t adjYear;
…
void setup(){
 …
}

void loop(){
 …
   if (GPS.fix){
//  This is our first user-defined function. Functions are ideally either
//  code that gets repeated a lot or they are included to modularise a long
//  program. If a block of reasonably self contained code that performs a
//  function that can be described in a few words, it's a good idea to turn
//  it into a user-defined function. Plus if any bugs appear, it's easier
//  to find them in 20 lines instead of 200 or more lines.
     constructStrings();
   }
   …
}

void constructStrings(){
//  As we are going to be using concatenation to construct our strings, we
//  will first null the global variables so we don't end up with data
//  running on in the strings.
 dateString="";
 logTime="";
 displayTime="";

//  Next we take our time variables and populate them with GPS (and
//  timezone) data.
 adjHour = GPS.hour + timeZone;
 adjDay = GPS.day;
 adjMonth = GPS.month;
 adjYear = GPS.year;

//  Check our variable remains in the 0-23 range. Outside this range, we
//  will  need to adjust the day and possibly the month and year.
 if(adjHour>23){
//  If our adjusted hour is greater than 23, we will pull it back into
//  range by subtracting 24 from the adjusted hour variable and we add 1
//  to our adjDay variable.
   adjHour -= 24;
   adjDay++;
//  The double plus sign is shorthand for adjDay=adjDay+1. Similarly,
//  the double negative sign will subtract one from the variable.
 }

//  Ordinarily, this switch/case statement would go into the above
//  conditional, however due to the nature of what we are checking, it has
//  the same effect as a stand-alone function and it may even be easier to
//  follow along this way.
//
//  We want to check if the adjusted day is greater than the number of days
//  in the month and if so, adjust our date variables to the first of the
//  following month. So as we are checking for each month, we put our month
//  variable in the parentheses of the switch function.
 switch(adjMonth){
//  If the number appearing after 'case' equals the value of the variable
//  in the switch statement parentheses, the code after the colon is
//  executed
   case 1:
//  If the adjusted day is greater than the number of days in the month,
//  adjust to the first of the following month
     if(adjDay>31){
       adjDay = 1;
       adjMonth++;
     }
//  Break out of the switch/case function if the conditions were met and
//  the code executed.
     break;
   case 2:
//  Here, we check for a leap year by evaluating the year modulo 4. In
//  other words if the remainder of dividing the year by four is zero, it
//  is a leap year. There are of course exceptions, but given the next one
//  does not occur until the year 2100, I didn't bother including those.
//  Also note the use of double equalities. This evaluates a conditional
//  without assigning a new variable. Code written similarly to
//  'if(y=x){...}', has the effect of assigning the value of x to y, losing
//  the original value of y.
     if(adjYear%4==0){
       if(adjDay>29){
         adjDay = 1;
         adjMonth++;
       }
       break;
     }
//  If it's not a leap year we check if adjDay is greater than 28
     if(adjDay>28){
       adjDay = 1;
       adjMonth++;
     }
     break;
//  From here, it's pretty much the same, check if adjDay is greater than
//  the number of days in each month, adjust to the first of the following
//  month as required. That is to say, it is pretty much the same until we
//  get to December
   case 3:  
     if(adjDay>31){
       adjDay = 1;
       adjMonth++;
     }
     break;
   case 4:  
     if(adjDay>30){
       adjDay = 1;
       adjMonth++;
     }
     break;
   case 5:  
     if(adjDay>31){
       adjDay = 1;
       adjMonth++;
     }
     break;
   case 6:  
     if(adjDay>30){
       adjDay = 1;
       adjMonth++;
     }
     break;
   case 7:  
     if(adjDay>31){
       adjDay = 1;
       adjMonth++;
     }
     break;
   case 8:  
    if(adjDay>31){
       adjDay = 1;
       adjMonth++;
     }
     break;
   case 9:  
     if(adjDay>30){
       adjDay = 1;
       adjMonth++;
     }
     break;
   case 10:  
     if(adjDay>31){
       adjDay = 1;
       adjMonth++;
     }
     break;
   case 11:  
     if(adjDay>30){
       adjDay = 1;
       adjMonth++;
     }
     break;
//  As December is the last month, if adjDay is 32, we need to change that
//  to the first of January the following year.
   case 12:
     if(adjDay>31){
       adjDay = 1;
       adjMonth = 1;
       adjYear++;
       break;
     }
//  A default statement is included should none of the case conditionals
//  match the switch value. We do not have any such values, but it is a
//  good idea to include it anyway.
   default:
     break;
 }
}

Tidy Up Our Switch/Case Function

Our switch case function repeats quite a lot of similar code, so we will break that out into a separate user-defined function

…

void setup(){
 …
}

void loop(){
 …
}

void constructStrings(){
 …
 switch(adjMonth){
//  We have a significant amount of repeated code in our case conditionals
//  below. Examining these code blocks, we can see that the only real
//  difference is the number of days in each month.
//
//  So we will write a user-defined function called
//  CheckEndOfMonth(uint8_t) that takes the days in the month as a variable
//  passed from the function call that executes the code under the
//  conditionals. We cannot move the break function, only the conditional
//  and its code block
    case 1:
     checkEndOfMonth(31);
     break;
   case 2:
//  Unfortunately, February is going to remain an edge case as the number
//  of days in the month vary according to the year type. We can still call
//  our checkEndOfMonth(uint8_t) function, but we first need to evaluate
//  the number of days in the month. We will do this by once again
//  evaluating the value of adjYear modulo 4
      if(adjYear%4==0){
        checkEndOfMonth(29);
        break;
      }
      checkEndOfMonth(28)
     break;
   case 3:  
     checkEndOfMonth(31);
     break;
   case 4:  
     checkEndOfMonth(30);
     break;
   case 5:  
     checkEndOfMonth(31);
     break;
   case 6:  
     checkEndOfMonth(30);
     break;
   case 7:  
     checkEndOfMonth(31);
     break;
   case 8:  
     checkEndOfMonth(31);
     break;
   case 9:  
     checkEndOfMonth(30);
     break;
   case 10:  
     checkEndOfMonth(31);
     break;
   case 11:  
     checkEndOfMonth(30);
     break;
   case 12:
//  December is also an edge case as if adjDay is greater than 31, we are
//  not simply adjusting to the first day of the following month, we are
//  moving the month back to January and moving the year forward also
    if(adjDay<31){
      adjDay=1;
      adjMonth=1;
      adjYear++;
    }
   …
 }
}

void checkEndOfMonth(uint8_t eom){
//  By breaking out this code, we are saving a significant number of lines
//  of code and simplifying our function even further
 if(adjDay>eom){
   adjDay = 1;
   adjMonth++;
 }
}

Refine Code for Edge Cases


We should not just leave our edge cases ‘out there’, instead we should aim for comprehensiveness in our code. This is for the twin reasons of bug identification and human readability. To that end, we will revisit our switch/case and checkEndOfMonth(uint8_t) functions one more time to see what we can do about February and December.

…
void setup(){
 …
}

void loop(){
 …
}

void constructStrings(){
 …
 switch(adjMonth){
//  As alluded to above, functions should operate similar to essays. They
//  should comprehensively cover the subject they are written for,
//  including edge cases. So let's move the February and December code down
//  into our checkEndOfMonth(uint8_t) function.
   …
   case 2:
     checkEndOfMonth(29);
     checkEndOfMonth(28);
     break;
   …
    case 12:
     checkEndOfMonth(31);
   default:
     break;
 }
}

void checkEndOfMonth(uint8_t eom){
//  As both end of month values are sent for February, we double check that
//  for 29, if it is not a leap year, we return without evaluating the end
//  of month function for 29.
//
//  Similarly, if it is a leap year, we return without evaluating for an
//  end of month function for 28.
 if(eom==29) if(adjYear%4!=0) return;
 if(eom==28) if(adjYear%4==0) return;

//  If adjMonth is 12 i.e. the month of December
 if(adjMonth==12){
   if(adjDay>eom){
     adjDay = 1;
     adjMonth = 1;
     adjYear++;
   }
   return;
 }
//  And now evaluate whether adjDay is greater than the number of days in
//  the month, including both cases of February
 if(adjDay>eom){
   adjDay = 1;
   adjMonth++;
 }
}

Finish createDataStrings Function

Now that we have adjusted UTC to local time, we can move on to populating the dateString, displayTime and logTime string variables.

…
void setup(){
 …
}

void loop(){
 …
}

void constructStrings(){
 …
//  Population of our data strings will be done via concatenating each
//  element to the string from left to right. Concatenation in Arduino is
//  fairly simple, we just use += which as we remember from before adds or
//  in this case, concatenates the right hand side to the left hand side.
//
//  Starting with the date string, we will follow a DD/MM/YYYY pattern,
//  so if the day or month are single digits, a leading zero is added. Note
//  that the GPS parsing library returns a two digit year, so "20" is to be
//  added to the beginning of the year.

//  First to be populated is the dateSting
 if(adjDay<10) dateString += "0";
 dateString += adjDay;
 dateString += "/";
 if(adjMonth<10) dateString +="0";
 dateString += adjMonth;
 dateString += "/20";
 dateString += adjYear;

//  Print dateString to serial for visual inspection
 Serial.println(dateString);

//  Next, we move on to the logTime string.Again I add leading zeros where
//  time elements are a single digit.
 if(adjHour<10) logTime += "0";
 logTime += adjHour;
 logTime += ":";
//  Minutes and seconds, are taken directly from GPS data
 if(GPS.minute<10) logTime += "0";
 logTime += GPS.minute;
 logTime += ":";
 if(GPS.seconds<10) logTime += "0";
 logTime += GPS.seconds;

//  Again print to serial for visual inspection
 Serial.println(logTime);

//  Finally, we populate displayTime which will be in 12 hour time format,
//  so adjustment of adjTime to the 1…12 range is required
//  Adjust adjHour if required, using the same method used above if adjHour //  + timeZone was greater than 23.
//  Also populate our amPM variable.
 if(adjHour>12){
   adjHour -= 12;
   amPM = "pm";
 }
 else{
//  Twelve hour time does not have a Zero O'Clock
   if(adjHour==0) adjHour=12;
   amPM = "am";
 }

//  Now we can construct our displayTime string. There is no leading zero
//  for single digit hours this time, so we will use a space instead. Also,
//  we don't need to worry about seconds but we do need to add the amPM
//  variable as a suffix
  if(adjHour<10) displayTime += ” “;
 displayTime += adjHour;
 displayTime += ":";
 if(GPS.minute<10) displayTime += "0";
 displayTime += GPS.minute;
 displayTime += amPM;

//  Again print to serial
 Serial.println(displayTime);
}
…

Dehybridise Our createStrings() Function


Our createStrings() function is complete, but even on cursory examination, it has clearly crept out of scope into two functions. One that adjusts UTC from the GPS data to local time and date and another that creates or populates strings.

It is not a big deal, scope creep is fairly common and in this case, the delineation is fairly clear, so separation will be a straightforward affair.

…
void setup(){
 …
}

void loop(){
 …
}

void constructStrings(){
 dateString="";
 logTime="";
 displayTime="";

 adjustForTimeZone();

 if(adjDay<10) dateString += "0";
 dateString += adjDay;
 dateString += "/";
 if(adjMonth<10) dateString +="0";
 dateString += adjMonth;
 dateString += "/20";
 dateString += adjYear;
 
 if(adjHour<10) logTime += "0";
 logTime += adjHour;
 logTime += ":";
 if(GPS.minute<10) logTime += "0";
 logTime += GPS.minute;
 logTime += ":";
 if(GPS.seconds<10) logTime += "0";
 logTime += GPS.seconds;
 
 if(adjHour>12){
   adjHour -= 12;
   amPM = "pm";
 }
 else{
   if(adjHour==0) adjHour=12;
   amPM = "am";
 }
 displayTime += adjHour;
 displayTime += ":";
 if(GPS.minute<10) displayTime += "0";
 displayTime += GPS.minute;
 displayTime += amPM;
}
//  As we stated in the preamble to this section, a good function should be
//  comprehensive to one subject only.
//  Here we pull out the local time calculations and put them in their own
//  function called adjustForTimeZone() which we call from the
//  createDataStrings() function

void adjustForTimeZone(){
 adjHour = GPS.hour + timeZone;
 adjDay = GPS.day;
 adjMonth = GPS.month;
 adjYear = GPS.year;

 if(adjHour>23){
   …
 }
 switch(adjMonth){
   …

  }  
}
…

Get Speed Information


The process of writing the createDataStrings() function was a good beginner's dive into the nature of user-defined functions, code optimisation and even included a practical example of how to use the switch/case function.

Our next function concerns some of the other GPS data available, primarily speed, but we will also print our location data to the serial connection so we can see what it looks like.

…

//  We need to add another global variable for our speed value. Despite
//  velocity technically being a vector (which really bothers me if I think
//  about it too much, lol), I use it as the name of this variable. Also, a
//  float or a double could be used here, but even one decimal place is
//  overkill in terms of accuracy, so it really is not necessary
uint8_t velocity;
…

void setup(){
 …
}

void loop(){
 …
   if (GPS.fix) {
     …
//  Now we will write a function to call speed. As the raw value from the
//  GPS data is in knots, we will need to convert to km/h. Let's call our
//  function determineSpeed()
     determineSpeed();
   }
   …
 }
}
…
void determineSpeed(){
//  One knot is 1.852 km/h. As velocity is an integer type, it drops any decimal value
 velocity = GPS.speed * 1.852;

// Print this to serial:
 Serial.print("Speed: ");
 Serial.println(velocity);
 
//  While this will be removed later, we may as well print the latitude and
//  longitude readings to serial just to see what they look like and make
//  sure they display correctly. They will be logged later.

Serial.print("Latitude: ");
//  We want the latitude in digital form rather than degrees, minutes and
//  seconds format. At 5 decimal places, our GPS latitude and longitude
//  readings have a precision of plus or minus 0.55m
Serial.println(GPS.latitudeDegrees,5);

Serial.print("Longitude: ");
Serial.println(GPS.longitudeDegrees,5);
Serial.println();
}

End of GPS-Only Code


Here we are done with GPS-only coding. The next section is Display module coding.

Display Module-Specific Code

Diagnostic Check of the Module


Again, as we did when starting with the GPS module, before diving into the code, we write a skeleton sketch just to ensure we include the correct library dependencies, we correctly declare our Display class (in this case OLED) and we can instantiate our class correctly.

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

//  Create our address class and name it OLED. As this is a featherwing, it
//  uses I2C for connection, which is why include the Wire.h library and
//  specify it below with an ampersand.
Adafruit_SSD1306 OLED = Adafruit_SSD1306(128, 32, &Wire);
//  Note, creating a class is not strictly necessary, but your code will
//  definitely be harder to read without it as you will constant be calling
//  Adafruit_SSD1306(128,32,&WIRE) instead of OLED everytime you want to
//  change the display.

void setup(){
//  Instantiate the display at address location 3C. We use 8 bits or one
//  byte written in hexadecimal for our I2C location. This I2C location
//  will be set by the manufacturer
 OLED.begin(SSD1306_SWITCHCAPVCC, 0x3C);
//  By not clearing the buffer first, we show the Adafruit logo.
//  Personally, I like it, so I usually leave it in, but my daughter did
//  not, so you'll see later that I remove it by deleting the following two
//  lines and starting with the OLED.setTextColor(SSD1306_WHITE); line
 OLED.display();
 delay(1000);

//  I put the color of the text in the setup function so I don't need to
//  worry about it later. You do need to specify white text though,
//  wherever you set the text color.
 OLED.setTextColor(SSD1306_WHITE);
//  The below clears the buffer and then the line following displays the
//  cleared buffer  (i.e. an empty screen).
 OLED.clearDisplay();
 OLED.display();
}

void loop() {
//  Set the cursor in the upper left corner. Note that the text writes down
//  and to the right, not up and to the right as you may be used to.
 OLED.setCursor(0,0);
//  There are three sizes of text available in the 128x32 OLED module.
//  These are, somewhat unsurprisingly, sizes 1, 2 and 3.
 OLED.setTextSize(2);
//  Print a test message to the buffer
 OLED.print("Display works!");
//  Display the buffer i.e. the test message
 OLED.display();
//  Stop here, using our halting code. The display will either show the
//  message or (very unlikely) there is a problem with either the code
//  written to this point or (even more unlikely) the display board is
//  faulty.
 while(true);  
}

Determine Display Element Positioning of km/h


Now that we know our display is working, we can start working out the placements of the elements we wish to display. We will start with the positioning of the km/h element. To do this, we will use the ancient engineering method the Egyptians used when building the pyramids, taking an educated guess and refining from there (lol).

…
void setup(){
 …
}

void loop(){
//  We want to place a size 1 "km/h" to as right of the display as
//  possible. To work out where this is, we will start at x=100 and move
//  one pixel at a time to the right until we get the furthest x position
//  that does not wrap the text.

//  We have four characters with three spaces, so conservatively allowing
//  for 4 pixel wide characters at 1 pixel spacing, it shouldn't go further
//  than position 108, but we'll end the loop at 110, just to be sure. A
//  one second delay between displays should be plenty of time to determine
//  the point text wrapping happens

 for(uint8_t i=100; i<111; i++){
//  Clear the display for each loop
   OLED.clearDisplay();
   OLED.setTextSize(1);
//  We want to write the x value of the km/h text on the left hand side of
//  the screen
   OLED.setCursor(0,0);
   OLED.print(i);

//  Now place the km/h text
   OLED.setCursor(i, 0);
   OLED.print("km/h");

//  Display both the value and the text
   OLED.display();

//  Delay for 1 second
   delay(1000);
 }
}

Determine the Remaining Elements Positioning

So now we know the km/h positioning in the x-axis should be at x=102, we can follow the same procedure for the remaining elements. I have ‘precooked’ the speed element positioning as writing another loop for calculating where this should go is somewhat redundant given we already did that in the previous section. Instead, this section focusses on verifying everything fits without jumping around during changes in speed and that there are no elements overlapping each other.

…
//  Declare a 3 value array of 8 bit integers populated with dummy speeds
uint8_t dummySpeedArray[3] = {8,58,108};
…
void loop(){
//  let's try some dummy readings for the speed output. We have
//  pre-populated our dummy speed array with the values 8, 58 and 108 to
//  ensure a good range of differing speeds. But this time instead of
//  manually setting the cursor each time, we will use conditionals. As
//  stated in the preamble of this section, I have already determined the x
//  values using the method in the previous screen. So now we are checking
//  they are correct.
 for(uint8_t i=0; i<3; i++){
   OLED.clearDisplay();
   OLED.setTextSize(3);
//  Here we start at the furthest right position, moving left as the
//  numbers get longer. We could nested conditionals, but the logic of
//  moving left for longer speeds is clearer this way
   OLED.setCursor(84,0);
   if(dummySpeedArray[i]>9) OLED.setCursor(66,0);
   if(dummySpeedArray[i]>99) OLED.setCursor(48,0);
//  Print the dummy value on the display
   OLED.print(dummySpeedArray[i]);
// Place a dummy clock at the far left, about 3/4 of the way down
    OLED.setTextSize(1);
   OLED.setCursor(0,24);
   OLED.print("12:38pm");
//  Display all printed elements. Also, We can set our delay much shorter
//  here as we are only checking that nothing jumps around and are not
//  taking note of positions, as those were determined using the approach
//  in the previous section.
   OLED.display();
   delay(200);
 }
}

End of Display-Only code


This marks the end of the display-only code. There are a few final notes below and then we move on to the SD-only code.

Now that everything fits laterally, I played around a bit with the vertical positioning of the speedo and the units and found that 8 pixels down or y=8 centres the speed reading nicely and 4 pixels down or y=4 puts the units in a readable, yet unobtrusive position.

The elements changed are the km/h element which becomes OLED.setCursor(102,4) and the speed display elements which become OLED.setCursor(84,8), OLED.setCursor(66,8), and OLED.setCursor(48,8)

SD Module-Specific Code

Diagnostic Check of the Module

One more time, we will construct a skeleton sketch to ensure we include the correct library dependencies, declare our SD class correctly and are able to instantiate our declared class. Make sure you have a Micro-SD card formatted to FAT32 and that it is inserted correctly into the SD module.

//  SD cards are kind of picky, it's best (to the point of necessity) to
//  use at least the faster SPI connection. So we need to include SPI.h,
//  the SPI library
#include <SPI.h>
#include <SD.h>

//  As we are using the Adalogger, our chip select pin will always be 4.
//  
//  We don't need to worry about declaring the SCK(System Clock), MISO
//  (Microcontroller In, Slave Out) or MOSI (Microcontroller Out, Slave In)
//  pins as we are using hardware SPI, not software SPI. Also, as our
//  select pin does not change, we declare it a constant with name
//  onBoardSD.
const uint8_t onBoardSD = 4;

void setup() {
//  Start our serial connection so we can see what is going on
 Serial.begin(9600);
//  A lot of example code includes a statement that halts the sketch until
//  a serial connection is made with the computer. I personally don't think
//  it is necessary with modern Arduino boards, but you can leave or
//  include it if it is your preference. Just be sure to comment out or
//  delete this halting code before you try to use your device away from a
//  computer. Otherwise it will hang indefinitely, waiting for a serial
//  connection. This one line is responsible for a huge number of issues
//  with students I have tutored.

//  Below is an interesting example on the nature of how booleans are
//  handled across multiple code types. You can often call a boolean check
//  on non-boolean variables and they return true if they are any value
//  other than zero or false. You can also add booleans into another
//  variable type. For example, in my Conroy’s Game of Life Sketch, I add
//  true values together into an integer to get the number of neighbors a
//  cell has.
//
//  Also, by calling a boolean check on a function, we execute the function //  while also evaluating whether it executed, performing two functions in
//  one line of code.
 if (!SD.begin(onBoardSD)){
   Serial.println("Card not present or device not working");
//  If you see this message, make sure your SD card is formatted to FAT32
//  and is correctly inserted. There is a minimal chance the SD module on
//  the Adalogger is not working, particularly if the Adalogger itself is
//  still running sketches.
//  
//  Stop here and check your card if you see the above message.
   while (true);
 }
//  Everything is good if you see the below message
 Serial.println("Card found!");
}

void loop() {
//  This function has two effects. If the DataTest.txt file does not exist
//  on the SD card, the function creates it. If the file DataTest.txt is
//  already present on the SD card, it opens it. The FILE_WRITE suffix
//  allows it to be written to. It's best to keep the file name length at 8
//  characters or less, otherwise the SD module can get a bit goofy.
//
//  Also File is a variable type under the SD library, so it can be
//  declared locally or globally.

 File testFile = SD.open("DataTest.txt",FILE_WRITE);
 if(testFile){
   testFile.close();
   Serial.println("File created!");
 }
 else{
   Serial.println("File not created...");
 }
//  We have created DataTest.txt on the SD card and closed the file. Pay
//  attention to the serial output to ensure there is no problem.
//
//  Now we will reopen the file and write some data to it.
 testFile = SD.open("DataTest.txt",FILE_WRITE);
 if(testFile){
   testFile.println("File text goes here.");
   testFile.close();
   Serial.println("File successfully reopened and written to!");
 }
 else{
   Serial.println("Problem opening the test file...");
 }
//  Stop here and check the SD card with a computer. You should have one
//  file called 'DataTest.txt' that contains the text 'File text goes
//  here'. If that is the case, everything is good. If you have two files,
//  double check the filenames are the same in both
//  testFile=SD.open("DataTest.txt",FILE_WRITE) statements. If the file is
//  there, but it is blank, your SD card is likely faulty.
 while(true);
}

File type, Variable Type and Headers

Now we will show how the log file can be recorded as type CSV and how the File variable can be declared as a global variable. We use a variable instead of a string to open our data file and write headers to our new CSV file.

…
//  The file name can be declared as a global variable should you wish to
//  change the name of the file according to received or measured data
String fileName;
//  We can also declare a variable of type File globally if required.
File logFile;
void setup(){
 …
}

void loop(){
//  We can change the suffix to .csv without any problems here and it makes //  it easier to open in Excel for further manipulation
 fileName="dataFile.csv";
//  We can use the File variable as well as the name variable in place of
//  the static string used in the previous screen
 logFile= SD.open(fileName,FILE_WRITE);
 if(logFile){
   logFile.close();
   Serial.println("File created!");
 }
 else Serial.println("File not created...");
//  Reopening the file, we can add headers. Note the use of println instead
//  of print. This finishes the line in the log file so that further
//  writing to the file will occur underneath the headers.
 logFile = SD.open(fileName,FILE_WRITE);
 if(logFile){
   logFile.println("Date,Time,Latitude,Longitude,km/h");
   logFile.close();
   Serial.println("File successfully reopened and Headers written!");
 }
 else{
   Serial.println("Problem opening the test file...");
 }
 while(true);
}

End of SD-only code

This ends our SD-only code as well as our module-specific code. From this point forward we will begin incorporating multiple modules into our sketch, starting with the Display and GPS modules before adding the SD module.

Cross-Module Sketch Construction

GPS and Display modules

Here we incorporate the GPS library dependencies with the Display library dependencies and construct a diagnostic display sketch and use it to show start-up information instead of writing this information to serial. I use the full sketch we wrote for the GPS module and add the display module elements to that.

Note that I truncate much of the code that is not in focus at this time.

//  This step adds display libraries and class declaration/ instantiation
//  statements to the GPS sketch we ended at earlier.
//  
//  We also move some of the diagnostic information from the Serial/
//  computer connection to the display. A short function primarily for
//  diagnostic information display that shows a passed string and waits for
//  a passed interval is also written. We will call this function
//  progressMessage(string,interval)
#include 
//  Display libraries:
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

//  GPS class creation command:
Adafruit_GPS GPS(Serial1);
//  OLED class creation command:
Adafruit_SSD1306 OLED = Adafruit_SSD1306(128, 32, &Wire);
//  Previous variable declarations (truncated):
…
void setup(){
 Serial.begin(115200);
//  We instantiate the OLED first so that we may display diagnostic
//  information via the progressMessage(string,interval) function we have
//  written below. Note that each initialisation command is in their own
//  blocks separated by a blank line.
 OLED.begin(SSD1306_SWITCHCAPVCC, 0x3C);
 OLED.setTextColor(SSD1306_WHITE);
//  I show a title here to demonstrate the display is working. Stinky is
//  the nickname I have for my daughter. She hates it, so it's hilarious
//  and I use it whenever I can.
 progressMessage("Stinky's GPS Logger",1500);

//  Instantiate the GPS module, and display diagnostic information
 if(GPS.begin(9600)) progressMessage("GPS Initialised!",1000);
 else progressMessage("GPS could not be initialised",1000);
 GPS.sendCommand(PMTK_SET_NMEA_OUTPUT_RMCGGA);
 GPS.sendCommand(PMTK_SET_NMEA_UPDATE_5HZ);
}

void loop(){
 …
 if (millis() - timer > 750){
   …
   }
   else{
//  We move the 'no GPS fix' animation to the display using our
//  progressMessage(string,interval) function
     progressMessage("-No GPS Fix-",100);
     progressMessage("|No GPS Fix|",100);
   }
 }
}
//  The progressMessage function is called before any other user-defined
//  function, so we place it first in the list of user-defined functions.
//  This is not mandatory, it can be put in the middle or at the end and
//  the sketch will work the same. But I do it this way to keep a logical
//  sequence for human reading of the code.
void progressMessage(String msg, uint32_t milliCount){
 OLED.clearDisplay();
//  Set the cursor roughly vertically central so as to draw the eye to it.
//  It usually contains important information we want the user to see.
 OLED.setCursor(0,14);
//  Set the text size to 1 so more information can be displayed.
 OLED.setTextSize(1);
 OLED.print(msg);
//  Display the message and hold for the specified number of milliseconds
 OLED.display();
 delay(milliCount);
}

void constructStrings(){
…
}

void adjustForTimeZone(){
…
}

void checkEndOfMonth(uint8_t eom){
 …
}

void determineSpeed(){
 …
}

Show GPS Information on the OLED Display


Here we incorporate the GPS library dependencies with the Display library dependencies and construct a diagnostic display sketch and use it to show start-up information instead of writing this information to serial. I use the full sketch we wrote for the GPS module and add the display module elements to that.

Note that I truncate much of the code that is not in focus at this time.

…
void setup(){
 …
}

void loop(){
 …
 if (millis() - timer > 750){
   timer = millis();
   if (GPS.fix){
     constructStrings();
     determineSpeed();


     displayTimeSpeed();
   }
   else{
     …
   }
 }
}
…
void determineSpeed(){
 …
}

void displayTimeSpeed(){
//  We will start by adding the time and speed units to the display after
//  clearing it
 OLED.clearDisplay();
 OLED.setTextSize(1);
//  12 hour clock on the left:
 OLED.setCursor(0,24);
 OLED.print(displayTime);
//  Speed units (km/h) on the right:
 OLED.setCursor(102,4);
 OLED.print("km/h");
//  Now, for the value of the velocity variable, we set the text size to
//  and use our conditionals determined earlier for location
 OLED.setTextSize(3);
 OLED.setCursor(84,8);
 if(velocity>9) OLED.setCursor(66,8);
 if(velocity>99) OLED.setCursor(48,8);
 OLED.print(velocity);
//  Display all elements on OLED screen. We don’t bother with a delay interval here as this will automatically update based on our update interval we will set later.
 OLED.display();
}

Add SD Module Code to Sketch


Here we add library dependencies for the SD module to our sketch and initialise the module with the diagnostic information displayed on the OLED screen

  • This step adds the SD module libraries and initialisation statements to the previous combination of GPS and Display sketches.
#include <Adafruit_GPS.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
//  SD module libraries:
#include <SPI.h>
#include <SD.h>

Adafruit_GPS GPS(&Serial1);
Adafruit_SSD1306 OLED = Adafruit_SSD1306(128, 32, &Wire);
//  As the SD module operates via hardware SPI, we do not need to create an
//  SD class here, it is handled in the SD setup below

//  We can declare a global variable of type File here. Using a global
//  variable is a bit of a waste where a local variable can be used, but
//  our Adalogger has plenty of RAM and memory, so let's declare it here as 
//  a proof of concept:
File logFile;
…
//  Declare our chip select pin with name onBoardSD here:
const uint8_t onBoardSD = 4;
// Variable declarations (truncated):
…

void setup(){
 …

//  We add our instantiation (or more accurately, initialisation) command
//  block below, moving diagnostic feedback from serial to the display
 if (!SD.begin(onBoardSD)){
   progressMessage("SD could not be initialised...",1000);
   progressMessage("This trip will not be logged",1000);
 }
 else progressMessage("SD module initialised!",1000);
 …
}

void loop(){
 …
}
//  User defined functions (truncated):
…

Log Data to SD Card


Now we will write a function for logging our data to the SD card. To do this, we must first create a log name and then use that log name to create our CSV file. We are limited to 8 characters in our log name, so we will use a reverse date of month and day only, followed by 24-hour time.

Now we will include a logging function for writing GPS data to the SD card. We will call this function logData() and conditionally call it after the determineSpeed function. As the actual function is the last to be called, we place this:

//  We will call our logging function logData() and we will conditionally
//  call it after the determineSpeed() function. As this function is the
//  last to be called, we place this at the end of the user-defined
//  functions.
//
//  Library dependencies and class declarations (truncated):
…

//  We are going to declare two boolean variables here. firstRun will be
//  used to ensure only one log file is created per session, and
//  logAvailable will be used to determine whether a log file is available
//  to be written to.
bool  firstRun=true;
bool  logAvailable;
//  Variable declarations (truncated):
…

//  We declare our file name string here:
String logFileName;
//  We are going to populate this file name with a string comprised of date
//  and time elements, so we can add some extra code to our
//  constructStrings() function, or we can append it to a logging function.
//  It will be appended it to the logging function as it is logging
//  adjacent but will only need to be run once.
//  Variable declarations cont (truncated):
…

void setup(){
//  OLED instantiation (truncated):
  …
 
 if (!SD.begin(onBoardSD)){
   progressMessage("SD could not be initialised...",1000);
   progressMessage("This trip will not be logged",1000);
//  As there is a problem initialising the SD module, the log will not be
//  available, so we set the logAvailable boolean to false.
   logAvailable = false;
 }
 else{
   progressMessage("SD module initialised!",1000);
//  The SD module is initialised, so we can assume the log file can be
//  created. As the log file creation command block is contained within the
//  logData() function, we set the logAvail boolean to true.
   logAvailable = true;
 }

//  GPS instantiation (truncated):
  …
}

void loop(){
 …
   if (GPS.fix){
     constructStrings();
     determineSpeed();
//  We will only call the logData function if the log is available or potentially available.
     if(logAvailable) logData();
   }
   …
}

void progressMessage(String msg, uint32_t milliCount){
 …
}

void constructStrings(){
 …
}

void adjustForTimeZone(){
 …
}

void checkEndOfMonth(uint8_t eom){
 …
}

void determineSpeed(){
 …
}

void logData(){
//  If this is the first time our function is called, we first need a log
//  file name and a log file with headers. Because we are limited to 8
//  characters in the file name, we will call our log file MMDDHHmm.csv
//  This can result in collisions in the unlikely event we start a drive on //  the exact same date across multiple years iff (if and only if) they
//  begin at the exact same hour and minute.
 if(firstRun){
//  Not strictly necessary to set the logFileName string to null, but it's
//  best to do anyway.
   logFileName="";
//  We add leading zeros if required as we want uniformity across our
//  naming convention.
   if(adjMonth<10) logFileName += "0";
   logFileName += adjMonth;
   if(adjDay<10) logFileName +="0";
   logFileName += adjDay;
   if(adjHour<10) logFileName += "0";
   logFileName += adjHour;
   if(GPS.minute<10) logFileName +=0;
   logFileName += GPS.minute;
   logFileName += ".csv";

//  Add headings to the first line of our log file
   logFile = SD.open(logFileName,FILE_WRITE);
   if(logFile){
     logFile.println("Date,Time,Latitude,Longitude,km/h");
     logFile.close();
   }
   else{
     progressMessage("SD Card could not be written to.",1000);
     progressMessage("The rest of this trip will not be logged",1000);
//  Something went wrong writing to the log file. We set the logAvailable
//  variable to false so there are no more attempts to write to a corrupt
//  card.
     logAvailable = false;
//  Return to main loop without executing any more code in this function
     return;
   }
//  We do not want to create a new log file for every new log entry, so we
//  set firstRun to false to ensure the above code is run only once.
   firstRun = false;
 }
//  Add a new date string, a new time string, latitude, longitude and
//  converted speed with the ',' delimiter in between each datum
 logFile = SD.open(logFileName,FILE_WRITE);
 if(logFile){
   logFile.print(dateString);
   logFile.print(",");
   logFile.print(logTime);
   logFile.print(",");
   logFile.print(GPS.latitudeDegrees,5);
   logFile.print(",");
   logFile.print(GPS.longitudeDegrees,5);
   logFile.print(",");
   logFile.println(velocity);
   logFile.close();
 }
 else{
     progressMessage("SD Card could not be written to.",1000);
     progressMessage("The rest of this trip will not be logged",1000);
//  As above, it seems something went wrong writing to the log file. We set
//  the logAvailable variable to false so there are no more attempts to
//  write to a corrupt card
     logAvailable = false;
   }
}

Dehybridise Logging Function

As before, we have found ourselves having written a function that does two things instead of one. One of these things (creating a file name for our log) is done only once, whereas the other thing (logging data) is done on an ongoing basis. We will separate these into two different functions.

…
void logData(){
//  The code in the firstRun conditional is mostly string construction. Any
//  writing to the log file is only writing static headers as opposed to
//  dynamic data collected from or calculated from the GPS module. So it
//  does not really fit with the rest of the function. We pull this code
//  out and put it in its own function called initialiseLog().
 if(firstRun) initialiseLog();
//  By pulling out the initialising command block, we now have a situation
//  where the log could potentially be unwriteable while the following code
//  may still be executed. Although somewhat unlikely, we can end end up
//  with a buffer overflow or possibly even stranger behavior. To prevent
//  this, we will check logAvailable and if false, we will return to the
//  main loop.
 if(!logAvailable) return;
//  Now data logging will only occur if logAvailable==true
 logFile = SD.open(logFileName,FILE_WRITE);
 if(logFile){
   logFile.print(dateString);
   logFile.print(",");
   logFile.print(logTime);
   logFile.print(",");
   logFile.print(GPS.latitudeDegrees,5);
   logFile.print(",");
   logFile.print(GPS.longitudeDegrees,5);
   logFile.print(",");
   logFile.println(velocity);
   logFile.close();
 }
 else{
     progressMessage("SD Card could not be written to.",1000);
     progressMessage("The rest of this trip will not be logged",1000);
     logAvailable = false;
   }
}

void initialiseLog(){
 logFileName="";
 if(adjMonth<10) logFileName += "0";
 logFileName += adjMonth;
 if(adjDay<10) logFileName +="0";
 logFileName += adjDay;
 if(adjHour<10) logFileName += "0";
 logFileName += adjHour;
 if(GPS.minute<10) logFileName +=0;
 logFileName += GPS.minute;
 logFileName += ".csv";

 logFile = SD.open(logFileName,FILE_WRITE);
 if(logFile){
   logFile.println("Date,Time,Latitude,Longitude,km/h");
   logFile.close();
 }
 else{
   progressMessage("SD Card could not be written to.",1000);
   progressMessage("This trip will not be logged",1000);
   logAvailable = false;
 }
 firstRun = false;
}

Finishing Up


At this point, our sketch is pretty much complete. However, running it as is, we may notice some strange behaviour in terms of the name of our log file as well as the first few lines of data written. This is because as written, our code starts running at the first GPS fix returning true. There may be some null values being returned, so we want to delay our start for a few cycles to ensure we have a GPS fix and that it is also stable.

Also, we are logging new data every ¾ of a second. This is great for accuracy, but it is terrible in terms of enormous log files that clog up our SD card. So, using a counter, we will bump that back to logging data every 12 seconds. This is still reasonably high accuracy and results in much more manageable file sizes.

//  First we will set a logging interval by using a counter and then we
//  will run through the reading, calculation and logging process without
//  recording anything for, say, the first three cycles.
//  
//  Include libraries and declare classes (truncated):
…
//  Declare variables (truncated):
…
//  Here we will add two 8 bit variables. One for read interval and the
//  other for start up delay. The start delay will be used in the main
//  loop, and the interval counter will be used in the logData() function
uint8_t intervalCounter;
uint8_t startDelayCounter = 3;

void setup(){
 …
}

void loop(){
 …
 if (millis() - timer > 750){
   timer = millis();
   if(startDelayCounter>0){
//  We have to move our 'No GPS fix' animation up here as the main loop
//  will not continue until a stable GPS fix is found. We will reword it
//  also as there may have been between 1 and 3 fixes found
     progressMessage("-GPS not yet stable-",100);
     progressMessage("|GPS not yet stable|",100);
//  Variable startDelayCounter will start reducing once GPS fixes have been
//  found
     startDelayCounter -= GPS.fix;
//  Restart loop() until 3 GPS fixes have been returned
     return;
   }
   …
 }
}

void progressMessage(String msg, uint32_t milliCount){
 …
}

void constructStrings(){
 …
}

void adjustForTimeZone(){
 …
}

void checkEndOfMonth(uint8_t eom){
 …
}

void determineSpeed(){
 …
}

void displayTimeSpeed(){
 …
}

void logData(){
//  Examining our millis()-timer conditional above, we can see logData() is
//  called approximately every 3/4 of a second. By setting our interval
//  counter to trip once every 16 calls, we will be logging data every 12
//  seconds. This can be adjusted later by changing the value in the
//  conditional.
 if(intervalCounter<12){
   intervalCounter++;
   return;
 }
 else{
//  Data logging code (truncated): 
}

void initialiseLog(){
 …
}

Troubleshooting

We have now completed our data logging sketch and it is good to go! A few things before we go:

  • If you have included while(!Serial); in the second line of your setup code, this must now be commented out or deleted, otherwise your logger will not work.
  • If there are any problems writing to your SD card, make sure it is FAT32 formatted and correctly inserted. Also, filenames over 8 characters (the filename is the part to the left of .csv) will not work
  • The SD module needs a reasonable amount of power to work, so try to get a power adapter that supplies at least 2A
  • StackOverflow. Google. Core Electronics Forums. There is truly exhaustive information available online.
  • It’s almost never the hardware that’s the problem.
  • A full, uncommented sketch is below, under Addendum 3.

Calculating Speed

The Haversine Function


The Haversine Function (Haversine distance, Law of Haversines, etc.) is an important function in spherical geometry for calculating the linear distance across a spheres surface between two points.

It’s a fairly hefty formula when written in full, so generally after converting the degrees to radians, it is broken down into three parts:

       a = sin2(Average(Latitude1, Latitude2)) + cos(Latitude1)*cos(Latitude2)*sin2(Average(Longitude1, Longitude2))

       b = 2*atan2(sqrt(a), sqrt(1-a))

       Distance = 6,371* b

The mean radius of the Earth is 6371km, so our distance will be in km.

It looks far more intimidating than it is, in fact, it was calculated fairly easily in Excel when preparing for court. Once the distances were calculated, it was just a matter of dividing by the time between each location.

Remember, there are 3600 seconds in an hour, so your calculation should look something like this:

        0.20833/12 * 3600 = 62.65km/h

Using this formula gives the most accurate approximation of speed between two location points.

Adaptation to the Excel Environment


Adapting the log information to Excel is already done. Just left-click the file and open it in Excel. Your file will have 5 columns with Date, Time, Latitude, Longitude, and km/h.

To calculate the distance between recorded points using the Haversine Formula, we add the following five column headings in columns F to J: Latitude_r, Longitude_r, a, b, Distance.

Then we add the following formulae:

       In cell F2: =RADIANS(C2)

       In cell G2: =RADIANS(D2)

       In cell H3: =SIN(SIN(AVERAGE(F2,F3)))+COS(F2)*COS(F3)*SIN(SIN(AVERAGE(G2,G3)))

       In cell I3: =2*ATAN2(SQRT(H3),SQRT(1-H3))

       In cell J3: =6371*I3

 

Note that cells H2 through J2 are blank as there is no location above F2 and G2.

To extend this down, starting at cell G2, just double-click the bottom right corner of the cell and the values will auto-populate, calculating on the way down. Continue with cell G2 and then we will do the same for cells H3 through J3.

To calculate the speeds, we add the heading True Speed in column K.

We will note that Excel records time data as a fraction of one i.e. 24 hours is recorded as 1, 3 hours are recorded as 0.125, etc, so we can’t just subtract the two times from each other and get a value we can divide the distance by. We must multiply the difference between timestamps by 86400 to get true seconds.

Then once we have divided the distance by true seconds, we have km/second, so we need to multiply again by the number of seconds in an hour (or 3600) to get our km/h reading.

Thus, in cell K3, we add the following formula:

 

       =(J3/((B3-B2)*86400))*3600

 

You will likely end up with a value that either looks like a date or a cell full of ####s. In either case, it’s just a formatting issue that can easily be resolved by right-clicking the top of the column (where the ‘K’ is), selecting Format Cells, and then selecting General under the Category list. This will then convert your cell or list to km/h readings.

Full, Uncommented Sketch

Below is the completed sketch which takes and manipulates GPS data before displaying the human-friendly components and writes a comprehensive log updated every 12 seconds.

#include <Adafruit_GFX.h>
#include <Adafruit_GPS.h>
#include <Adafruit_SSD1306.h>
#include <SD.h>
#include <SPI.h>
#include <Wire.h>

Adafruit_GPS GPS(&Serial1);
Adafruit_SSD1306 OLED = Adafruit_SSD1306(128, 32, &Wire);

bool firstRun = true;
bool logAvailable;

const uint8_t timeZone = 10;
const uint8_t onBoardSD = 4;

File logFile;

String amPM;
String displayTime;
String dateString;
String logFileName;
String logTime;

uint8_t adjDay;
uint8_t adjHour;
uint8_t adjMonth;
uint8_t adjYear;
uint8_t intervalCounter;
uint8_t startDelayCounter = 3;
uint8_t velocity;

uint32_t timer = millis();

void setup(){
 Serial.begin(115200);

 OLED.begin(SSD1306_SWITCHCAPVCC, 0x3C);
 OLED.setTextColor(SSD1306_WHITE);
 progressMessage("Stinky's GPS logger",1500);

 if(!SD.begin(onBoardSD)){
   progressMessage("SD could not be initialised...",1500);
   progressMessage("This trip will not be logged",1500);
   logAvailable = false;
 }
 else{
   progressMessage("SD module initialised!",1000);
   logAvailable = true;
 }

 if(GPS.begin(9600)) progressMessage("GPS Initialised!",1000);
 else progressMessage("GPS could not be initialised",1000);
 GPS.sendCommand(PMTK_SET_NMEA_OUTPUT_RMCGGA);
 GPS.sendCommand(PMTK_SET_NMEA_UPDATE_5HZ);
 }

void loop(){
 char c = GPS.read();
 if (GPS.newNMEAreceived()) if (!GPS.parse(GPS.lastNMEA())) return;
 if (millis() - timer > 750){
   timer = millis();
   if(startDelayCounter>0){
     progressMessage("-GPS not yet stable-",100);
     progressMessage("|GPS not yet stable|",100);
     startDelayCounter -= GPS.fix;
     return;
   }
   if (GPS.fix){
     constructStrings();
     determineSpeed();
     displayTimeAndSpeed();
     if(logAvailable) logData();
   }
 }
}

void progressMessage(String msg, uint32_t milliCount){
 OLED.clearDisplay();
 OLED.setCursor(0,14);
 OLED.setTextSize(1);
 OLED.print(msg);
 OLED.display();
 delay(milliCount);
}

void constructStrings(){
 dateString="";
 logTime="";
 displayTime="";

 adjustForTimeZone();
 
 if(adjDay<10) dateString += "0";
 dateString += adjDay;
 dateString += "/";
 if(adjMonth<10) dateString +="0";
 dateString += adjMonth;
 dateString += "/20";
 dateString += adjYear;
 
 if(adjHour<10) logTime += "0";
 logTime += adjHour;
 logTime += ":";
 if(GPS.minute<10) logTime += "0";
 logTime += GPS.minute;
 logTime += ":";
 if(GPS.seconds<10) logTime += "0";
 logTime += GPS.seconds;
 
 if(adjHour>12){
   adjHour -= 12;
   amPM = "pm";
 }
 else if(adjHour==12)  amPM="pm";
 else{
   if(adjHour==0) adjHour=12;
   amPM = "am";
 }
 displayTime += adjHour;
 displayTime += ":";
 if(GPS.minute<10) displayTime += "0";
 displayTime += GPS.minute;
 displayTime += amPM;
}

void adjustForTimeZone(){
 adjHour = GPS.hour + timeZone;
 adjDay = GPS.day;
 
 adjMonth = GPS.month;
 adjYear = GPS.year;

 if(adjHour>23){
   adjHour -= 24;
   adjDay++;
 }
 switch(adjMonth){
   case 1:
     checkEndOfMonth(31);
     break;
   case 2:
     checkEndOfMonth(29);
     checkEndOfMonth(28);
     break;
   case 3:
     checkEndOfMonth(31);
     break;
   case 4:
     checkEndOfMonth(30);
     break;
   case 5:
     checkEndOfMonth(31);
     break;
   case 6:
     checkEndOfMonth(30);
     break;
   case 7:
     checkEndOfMonth(31);
     break;
   case 8:
     checkEndOfMonth(31);
     break;
   case 9:  
     checkEndOfMonth(30);
     break;
   case 10:
     checkEndOfMonth(31);
     break;
   case 11:
     checkEndOfMonth(30);
     break;
   case 12:
     checkEndOfMonth(31);
   default:
     break;
 }
}

void checkEndOfMonth(uint8_t eom){
 if(eom==29) if(adjYear%4!=0) return
 if(eom==28) if(adjYear%4==0) return;
 if(adjMonth==12){
   if(adjDay>eom){
     adjDay = 1;
     adjMonth = 1;
     adjYear++;
   }
   return;
 }
 if(adjDay>eom){
   adjDay = 1;
   adjMonth++;
 }
}

void determineSpeed(){
 velocity = GPS.speed*1.852;
}

void displayTimeAndSpeed(){
 OLED.clearDisplay();

 OLED.setTextSize(1);
 OLED.setCursor(0,24);
 OLED.print(displayTime);

 OLED.setCursor(102,4);
 OLED.print("km/h");

 OLED.setTextSize(3);
 OLED.setCursor(84,8);
 if(velocity>9) OLED.setCursor(66,8);
 if(velocity>99) OLED.setCursor(48,8);
 OLED.print(velocity);
 OLED.display();
}

void logData(){
 if(intervalCounter<16){
   intervalCounter++;
   return;
 }
 else{
   intervalCounter=0;
   if(firstRun) initialiseLog();
   if(!logAvailable) return;
   logFile = SD.open(logFileName,FILE_WRITE);
   if(logFile){
     logFile.print(dateString);
     logFile.print(",");
     logFile.print(logTime);
     logFile.print(",");
     logFile.print(GPS.latitudeDegrees,5);
     logFile.print(",");
     logFile.print(GPS.longitudeDegrees,5);
     logFile.print(",");
     logFile.println(velocity);
     logFile.close();
   }
   else{
     progressMessage("SD Card could not be written to.",1000);
     progressMessage("The rest of this trip will not be logged",1000);
     logAvailable = false;
   }
 }
}

void initialiseLog(){
 logFileName="";
 if(adjMonth<10) logFileName += "0";
 logFileName += adjMonth;
 if(adjDay<10) logFileName +="0";
 logFileName += adjDay;
 if(adjHour<10) logFileName += "0";
 logFileName += adjHour;
 if(GPS.minute<10) logFileName +=0;
 logFileName += GPS.minute;
 logFileName += ".csv";

 logFile = SD.open(logFileName,FILE_WRITE);
 if(logFile){
   logFile.println("Date,Time,Latitude,Longitude,km/h");
   logFile.close();
 }
 else{
   progressMessage("SD Card could not be written to.",1000);
   progressMessage("This trip will not be logged",1000);
   logAvailable = false;
 }
 firstRun = false;
}

Have a question? Ask the Author of this guide today!

Please enter minimum 20 characters

Your comment will be posted (automatically) on our Support Forum which is publicly accessible. Don't enter private information, such as your phone number.

Expect a quick reply during business hours, many of us check-in over the weekend as well.

Feedback

Please continue if you would like to leave feedback for any of these topics:

  • Website features/issues
  • Content errors/improvements
  • Missing products/categories
  • Product assignments to categories
  • Search results relevance

For all other inquiries (orders status, stock levels, etc), please contact our support team for quick assistance.

Note: click continue and a draft email will be opened to edit. If you don't have an email client on your device, then send a message via the chat icon on the bottom left of our website.

Makers love reviews as much as you do, please follow this link to review the products you have purchased.