Software

Sketches to Test Hardware 

We will use some pre-written Arduino sketches to make sure your hardware is acting appropriately. Download these sketches and upload them to your Arduino with the compass shield and check their results against the expected outcome.

LED Check

First we'll test that your LEDs are soldered correctly. Download this sketch and upload to your Arduino with the compass shield attached. You should see a set of 4 LEDs light up at a time, lighting one quarter of the ring at a time. If any LEDs aren't lighting up, check their polarity and flip them if necessary.

LED Test Video
This video discusses how to test that you soldered the LEDs correctly.

Magnetometer Check

Next we'll check the magnetometer is working properly. Download this sketch and upload to your Arduino with the compass shield attached. Then, open the serial monitor and make sure you are set to 9600 baud rate.

Magnetometer Test Video
This video discusses how to test that your magnetometer is working correctly

You should see a number between 0 and 360 representing your magnetic heading. This number should loosely correspond to the compass' orientation, so try rotating it and make sure it correlates. It won't be accurate until calibration, but with this we at least know the magnetometer is functional!

Make the Compass Work 

The final code to make the compass work is available here. A detailed description of this code, how it works, and how it was created, is discussed below.

Putting it all together
This video demonstrates how the compass works with the final code uploaded.

Libraries and Definitions 

To start off, open a new Arduino sketch. This should have the functions void setup and void loop. We'll be adding some code outside of both, particularly above void setup.

Include Wire and EEPROM libraries

We will first include the Wire library to enable I2C communication with our magnetometer, which is our sensor that provides compass readings. As always, it's a good idea to include comments to notate the purpose of certain lines of code. Paste this line above void setup as shown.

Download file Copy to clipboard
1
2
3
4
// I2C Arduino Library
#include <Wire.h>

void setup(){

Do the same for the EEPROM library, whose header file is EEPROM.h

We use EEPROM later on to store calibration offsets.

Define Statements

A common tool in Arduino software is to use a define statement to name your pins. This way, when you're reading code, you'll know what a certain pin does as opposed to having to look at your connections to check its functionality.

This sketch only needs to directly modify the shift register pins. Pin 9 is connected to the data pin on the shift registers, so we'll define it as such.

Download file Copy to clipboard
1
#define data 9

We'll need to do the same for latch and clock, which are on pins 10 and 11 respectively.

Additionally, we will define the I2C address of the magnetometer. This code is still above void setup.

Download file Copy to clipboard
1
2
// address of HMC5883L magnetometer
#define address 0x1E

Variables

Next we'll want to initialize some more variables. To start, we want a way to easily refer to the angle at which each LED is positioned. To do this, we'll create a constant float that is equal to the number of degrees in a circle divided by the number of LEDs. This will create a wedge that is 1/16th of the circle, but we want the LED to be in the middle of each wedge. To do so, we will further divide the wedge in half (divide by 2).

Go ahead and fill in this line and include it in your sketch.

Download file Copy to clipboard
1
const float inc = (/*degrees in a circle*/ / /*# of LEDs*/) / 2;

We'll also create some empty containers for compass data.

Download file Copy to clipboard
1
2
3
4
5
// for raw compass data
int x,y,z;

// for processed compass data
float bearing;

Update Compass Function 

The core function of this project will be the one to request data from the HMC5883L magnetometer and turn it into usable X,Y, and Z integers.

Writing over I2C

This is done by first opening I2C communication to write two bytes, the first of which selects the mode register while the second selects continuous mode.

Download file Copy to clipboard
1
2
3
4
5
6
7
void updateCompassXYZ(){

  Wire.beginTransmission(address);
  Wire.write(0x02); // mode register
  Wire.write(0x00); // continuous mode
  Wire.endTransmission();
}

Reading from I2C

Next, we will request 6 bytes of data from the device. Once we see all 6, we read all the data in. The technique used here takes 2 bytes and bitshifts one of them in order to construct a 2-byte integer, that way we can store it in our x, y, and z containers.

Download file Copy to clipboard
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void updateCompassXYZ(){

  Wire.beginTransmission(address);
  Wire.write(0x02); // mode register
  Wire.write(0x00); // continuous mode
  Wire.endTransmission();

  Wire.requestFrom(address, 6);

  if( 6<=Wire.available() ){

    // Construct 2-byte integer from 2 single-byte reads from the HCM
    x = Wire.read()<<8 | Wire.read();
    z = Wire.read()<<8 | Wire.read();
    y = Wire.read()<<8 | Wire.read();
  }
}

Calibration Function 

Compasses, especially electronic ones, are prone to an effect known as hard-iron interference. More information on this type of interference can be found in Further Reading, but to summarize, a compass produces a circle of readings that is ideally centered at the origin of the axes. Hard-iron offsets 'push' this circle away from the origin, which skews the North/South/East/West directions.

Function Setup

In order to compensate for the offset, we can do some simple math to find the edges of this circle and re-center it. Let's begin by starting a function and sending a message over serial.

Download file Copy to clipboard
1
2
3
void calibrateCompass(){
  Serial.print("With compass on a level surface, spin the ");
  Serial.println("compass in a circle once or twice within 8 seconds");

The method we use involves finding the minimum and maximum X and Y values of the circle, so let's initialize the maximums as low values to guarantee they get overwritten, and vice versa for the minimums.

Download file Copy to clipboard
1
2
3
4
5
6
7
8
9
10
11
12
void calibrateCompass(){

  Serial.print("With compass on a level surface, spin the ");
  Serial.println("compass in a circle once or twice within 8 seconds");

  // Set high minimums and low maximums to guarantee they get overwritten
  int x_max = -5000;
  int y_max = -5000;
  int z_max = -5000;
  int x_min = 5000;
  int y_min = 5000;
  int z_min = 5000;

Lastly, we'll set up a flag to be used later. Place this after the last block of code.

Download file Copy to clipboard
1
int shiftFlag = 1;

Timing

We'll run the calibration for a few seconds. The user should have enough time to spin the compass manually twice, which will create the aforementioned 'circle of readings'. This while loop will use millis() to make sure the function runs for no longer than the time you define.

We suggest around 8 seconds (hint: 1 second = 1000ms)

Download file Copy to clipboard
1
2
long currTime = millis();
while(millis()-currTime < /*seconds to calibrate*/){

Compass Readings

Let's first grab data from the magnetometer immediately after the start of the loop. We'll also create a condition where we ignore any values above 2000 or below -2000, as the magnetometer can potentially 'spike', and we don't want these spikes affecting our readings.

Download file Copy to clipboard
1
if(abs(x) < 2000 && abs(y) < 2000 && abs(z) < 2000){  // Ignore spikes

Inside this if-statement, we'll save new maximums and minimums in their appropriate containers. This code will cover the maximum values, you'll need to write some code to do the same for minimums.

Download file Copy to clipboard
1
2
3
4
5
6
7
8
9
10
11
if(abs(x) < 2000 && abs(y) < 2000 && abs(z) < 2000){  // Ignore spikes

  // Save new max and min values
  x_max = max(x_max, x);
  y_max = max(y_max, y);
  z_max = max(z_max, z);

  /* x_min
     y_min
     z_min */
}

Calibration LED Indication

We'll also add some indication that the compass is calibrating so the user knows it won't be pointing north for a few seconds. The pattern we'll code will spin two opposite LEDs during calibration. The first thing to do is to mirror the currently lit LED on the opposite side of the compass.

Download file Copy to clipboard
1
2
3
4
5
6
if(!E_WNW){
  E_WNW = W_ESE;
}
else if(!W_ESE){
  W_ESE = E_WNW;
}

Our shift registers are 8 bits wide, so we use two of them to drive 16 LEDs. When an LED shifts out the end of one 8-bit section, we need it to appear at the beginning of the next 8-bit section.

When we add this code to the previous block, it will shift the LED as described above, but limited to only once every 100ms (we use shiftFlag here to facilitate this!)

Download file Copy to clipboard
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if(!E_WNW){
  E_WNW = W_ESE;
}
else if(!W_ESE){
  W_ESE = E_WNW;
}
if(E_WNW&0x01 && (millis()-currTime)/100 == shiftFlag){

  E_WNW = B10000000;
  W_ESE = B10000000;
  shiftFlag++;
} else if(W_ESE&0x01 && (millis()-currTime)/100 == shiftFlag){

  E_WNW = B10000000;
  W_ESE = B10000000;
  shiftFlag++;
}

Lastly, we'll add a statement that shifts the LED over 1 position within the 8-bit shift register if it is not transitioning between shift registers. Additionally we will update to the shift registers to manifest these changes on the LEDs.

Download file Copy to clipboard
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if(!E_WNW){
  E_WNW = W_ESE;
}
else if(!W_ESE)
  W_ESE = E_WNW;
if(E_WNW&0x01 && (millis()-currTime)/100 == shiftFlag){

  E_WNW = B10000000;
  W_ESE = B10000000;
  shiftFlag++;
} else if(W_ESE&0x01 && (millis()-currTime)/100 == shiftFlag){

  E_WNW = B10000000;
  W_ESE = B10000000;
  shiftFlag++;
} else if((millis()-currTime)/100 == shiftFlag){

  E_WNW = E_WNW >> 1;
  W_ESE = W_ESE >> 1;
  shiftFlag++;
  // shiftFlag makes sure the LEDs only move once per 100 milliseconds
}
updateShiftRegister();

Calibration Math

The calibration method involves taking the minimum and maximum values for all three axes and finding the difference between them max - min

Then we will divide this difference by 2 in order to get half the span of that axis (max - min) / 2

Next we will subtract that half-span value from the maximum to get the center of that axis max - ( (max - min) / 2 )

Ideally this value would be zero, but hard-iron offsets 'push' it away from zero. So instead, we can store this as an offset and later subtract it from all raw data in order to center the circle of readings. Write some equations to calculate these offsets for x, y and z.

Download file Copy to clipboard
1
2
3
x_off = /*math*/ ;
y_off = /*math*/ ;
z_off = /*math*/ ;

Writing Offsets to Memory

Now that we have properly calculated offsets, we will save them in persistent Arduino memory called EEPROM. EEPROM is divided into byte-wide sections, so our integers need to be split into two separate bytes before being stored. We do this by bit-shifting the upper 8 bits down and ANDing it with all 1's to ensure we only save the intended byte of information.

Put this code below the previous block, and write code to save y and z offsets as well.

Download file Copy to clipboard
1
2
3
//split 2-byte integer into single bytes to save in EEPROM
EEPROM.write(0, x_off & B11111111);
EEPROM.write(1, x_off>>8 & B11111111);

To finish this function, we'll print the offsets and close the function.

Download file Copy to clipboard
1
2
3
4
5
//print offsets at the end of calibration
Serial.println("x offset:  " + String(x_off));
Serial.println("y offset:  " + String(y_off));
Serial.println("z offset:  " + String(z_off));
}

Push to Shift Registers 

shiftOut is an Arduino function that does most of the work for you. updateShiftRegister includes shiftOut and will bring the shift register latch low, write the LED data in the appropriate order, and put the latch high again.

Download file Copy to clipboard
1
2
3
4
5
6
void updateShiftRegister(){
  digitalWrite(latch, LOW);
  shiftOut(data, clk, MSBFIRST, E_WNW);
  shiftOut(data, clk, MSBFIRST, W_ESE);
  digitalWrite(latch, HIGH);
}

Update LEDs 

This function includes a series of if-statements to check which portion of the circle the current heading is in. Because our variable inc is one half of a 'wedge', each if-statement will span 2*inc degrees to cover a whole wedge (1/16th of the circle). The first statement is unique in that its wedge spans between inc degrees and 360 - inc degrees, because it lies at the very top of the circle where 359 degrees meets 0 degrees.

Once the check is complete, we will bitshift a fixed-position bit NoSo to the position that most closely matches North. Since we are using two 8-bit shift registers and only activating one bit at a time, we will feed the non-active shift register all 0's (0x00).

Download file Copy to clipboard
1
2
//Set output data to always light the LED facing North.
if(bearing < inc  || bearing > 360-(inc)){ E_WNW = 0x00; W_ESE = NoSo;} else

Each subsequent if-statement simply checks the next wedge, each 2*inc wide. We will update shift registers at the very end so that we always light the correct LED.

Download file Copy to clipboard
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void updateLEDs(float bearing){
  //Set output data to always light the LED facing North.
  if(bearing < inc  || bearing > 360-(inc)){ E_WNW = 0x00; W_ESE = NoSo;} else
  if(bearing > inc && bearing < 3*inc){ E_WNW = 0x00; W_ESE = NoSo<<1;} else
  if(bearing > 3*inc && bearing < 5*inc){ E_WNW = 0x00; W_ESE = NoSo<<2;} else
  if(bearing > 5*inc && bearing < 7*inc){ E_WNW = 0x00; W_ESE = NoSo<<3;} else
  if(bearing > 7*inc && bearing < 9*inc){ E_WNW = NoSo>>4; W_ESE = 0x00;} else
  if(bearing > 9*inc && bearing < 11*inc){ E_WNW = NoSo>>3; W_ESE = 0x00;} else
  if(bearing > 11*inc && bearing < 13*inc){ E_WNW = NoSo>>2; W_ESE = 0x00;} else
  if(bearing > 13*inc && bearing < 15*inc){ E_WNW = NoSo>>1; W_ESE = 0x00;} else
  if(bearing > 15*inc && bearing < 17*inc){ E_WNW = NoSo; W_ESE = 0x00; } else
  if(bearing > 17*inc && bearing < 19*inc){ E_WNW = NoSo<<1; W_ESE = 0x00;} else
  if(bearing > 19*inc && bearing < 21*inc){ E_WNW = NoSo<<2; W_ESE = 0x00;} else
  if(bearing > 21*inc && bearing < 23*inc){ E_WNW = NoSo<<3; W_ESE = 0x00;} else
  if(bearing > 23*inc && bearing < 25*inc){ E_WNW = 0x00; W_ESE = NoSo>>4;} else
  if(bearing > 25*inc && bearing < 27*inc){ E_WNW = 0x00; W_ESE = NoSo>>3;} else
  if(bearing > 27*inc && bearing < 29*inc){ E_WNW = 0x00; W_ESE = NoSo>>2;} else
  if(bearing > 29*inc && bearing < 31*inc){ E_WNW = 0x00; W_ESE = NoSo>>1;}
  updateShiftRegister();
}

Setup 

void setup will begin I2C communication, serial communication, and define pins as inputs and outputs. Namely pin 5 is enabled as an input for the calibration button, with the internal pull-up resistor enabled.

Download file Copy to clipboard
1
2
3
4
5
6
7
8
void setup(void) {
  Wire.begin();
  Serial.begin(9600);
  pinMode(5, INPUT_PULLUP);
  pinMode(clk, OUTPUT);
  pinMode(latch, OUTPUT);
  pinMode(data, OUTPUT);
}

Main Loop 

Inside void loop, we will do just a few things. We'll check the calibration button, read and offset the compass data, convert it to a 360 heading, and print some things over serial.

Calibration Button

First we will check to see if the button is pressed, and run the call the calibration function if it is. Because we have a pull-up on the digital pin, its default state is HIGH. It will be brought LOW when pressed, so we invert the signal (!) to get the intended result.

Download file Copy to clipboard
1
2
3
4
5
void loop(){
  //When the button on Pin 5 is pressed, calibrate the magnetometer
  if( !digitalRead(5) ){
    calibrateCompass();
  }

Compass Data

We first call the function we wrote to read compass data from the magnetometer. Immediately after, we subtract the offsets we calculated from the data so that it is accurate.

Download file Copy to clipboard
1
2
3
4
updateCompassXYZ();
x -= x_off;
y -= y_off;
z -= z_off;

We want to represent the data as a 360 degree heading instead of raw x, y, and z, values. We will do so by using some math functions. First, we will calculate the angle that the x and y data forms with arctangent.

Download file Copy to clipboard
1
float bearing = atan2(y, x);

Next, we'll convert the answer, which is intrinsically in radians, to degrees by multiplying by 180 / 3.14, then offsetting the answer so that our result spans 0 to 360 instead of -180 to 180.

Download file Copy to clipboard
1
2
3
bearing *= 57.3;  //convert radians to degrees
if(bearing < 0)
  bearing += 360; //set range to 0 to 360 instead of -180 to 180

The last thing we will do in the main loop is update the LEDs so that they reflect the heading properly, as well as print the heading and add a short delay.

Download file Copy to clipboard
1
2
3
4
Serial.println(bearing);
updateLEDs(bearing);

delay(100);