ESP32: accelerometer and magnetometer (comparison with the micro:bit)

The micro:bit has a built-in accelerometer (measures acceleration and tilt) and a magnetometer (compass) – we used them in the lessons sensors: acceleration, tilt and pedometer and compass and temperature (in Serbian). The ESP32 board does not have these sensors, but we can easily add them as small modules. In this lesson we connect the two most common modules, read them without extra libraries and compare the results with the micro:bit.

What these sensors measure

  • An accelerometer measures acceleration along three axes (X, Y, Z) in units of g (1 g ≈ 9.81 m/s², the acceleration of gravity). When the sensor is at rest, it measures only gravity – that is why we can calculate the tilt from it. When we shake it, the values jump suddenly.
  • A magnetometer measures the magnetic field along three axes. The Earth's magnetic field is weak (about 48 µT in Serbia), but strong enough to find the direction of north, like a compass.

On the micro:bit (version 2) both sensors are in one chip, the LSM303AGR. For the ESP32 we use two separate, cheap modules:

ModuleChipMeasuresI2C address
GY-521MPU6050acceleration (3 axes) and angular velocity – gyroscope (3 axes)0x68
GY-271QMC5883Lmagnetic field (3 axes)0x0D
Careful with the GY-271: older modules have the HMC5883L chip (address 0x1E), newer ones the QMC5883L (address 0x0D). They look the same but are programmed differently. The I2C scanner below shows which one you have – the program in this lesson is for the QMC5883L.

Wiring over the I2C bus

Both modules use I2C – a connection with only two signal wires (SDA for data and SCL for the clock). Several devices can share the same two wires, because each has its own address. On the ESP32 the default I2C pins are GPIO21 (SDA) and GPIO22 (SCL).

MODULE (GY-521 and GY-271)  ESP32
VCC  ---------------------> 3V3
GND  ---------------------> GND
SDA  ---------------------> GPIO21
SCL  ---------------------> GPIO22
  • Both modules are connected in parallel: SDA to SDA, SCL to SCL.
  • The modules have their own regulator and I2C resistors, so they work at 3.3 V without extra parts.
  • Keep the magnetometer at least 10–15 cm away from motors, magnets, speakers and batteries – they “spoil” the compass.

Step 1: I2C scanner

Before writing the program, let us check that the ESP32 “sees” the modules. The scanner asks every address from 1 to 126 whether a device is there:

// I2C scanner – prints the addresses of all devices on the bus
#include <Wire.h>

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);                 // SDA = GPIO21, SCL = GPIO22
}

void loop() {
  Serial.println("Searching for I2C devices...");
  for (int address = 1; address < 127; address++) {
    Wire.beginTransmission(address);
    if (Wire.endTransmission() == 0) {    // 0 means: the device answered
      Serial.print("Device found at address 0x");
      Serial.println(address, HEX);
    }
  }
  delay(3000);
}

Expected result: 0x68 (MPU6050) and 0x0D (QMC5883L). If you see nothing – check the SDA and SCL wires (they are often swapped) and the power supply.

Step 2: the MPU6050 accelerometer

The sensor has internal registers – small memory cells with numbers. The program first writes 0 to register 0x6B to wake the sensor, and then in every loop reads 6 bytes starting from register 0x3B: two bytes each for X, Y and Z.

// MPU6050 – reading the accelerometer without an extra library
#include <Wire.h>
#include <math.h>

const int MPU = 0x68;                 // I2C address (0x69 if the AD0 pin is on 3.3 V)

int16_t read16() {
  int high = Wire.read();             // the high byte arrives first
  int low  = Wire.read();
  return (int16_t)((high << 8) | low);
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  Wire.beginTransmission(MPU);
  Wire.write(0x6B);                   // register PWR_MGMT_1
  Wire.write(0);                      // 0 = wake the sensor from sleep mode
  Wire.endTransmission();
}

void loop() {
  Wire.beginTransmission(MPU);
  Wire.write(0x3B);                   // first acceleration register (ACCEL_XOUT_H)
  Wire.endTransmission(false);
  Wire.requestFrom(MPU, 6, true);     // 6 bytes: X, Y, Z

  int16_t ax = read16();
  int16_t ay = read16();
  int16_t az = read16();

  // the default range is ±2 g: 16384 units = 1 g
  float gx = ax / 16384.0;
  float gy = ay / 16384.0;
  float gz = az / 16384.0;

  // tilt (in degrees) from the direction of gravity
  float pitch = atan2(gx, sqrt(gy * gy + gz * gz)) * 180.0 / PI;
  float roll  = atan2(gy, gz) * 180.0 / PI;

  Serial.print("mg  X: "); Serial.print(gx * 1000, 0);
  Serial.print("  Y: ");   Serial.print(gy * 1000, 0);
  Serial.print("  Z: ");   Serial.print(gz * 1000, 0);
  Serial.print("   pitch: "); Serial.print(pitch, 1);
  Serial.print("  roll: ");   Serial.println(roll, 1);
  delay(200);
}

From a raw number to g and degrees

  • Each axis is a 16-bit number from −32768 to 32767. In the default ±2 g range, 16384 = 1 g. That is why we divide by 16384.
  • When the module lies flat (chip up), we expect approximately X = 0, Y = 0, Z = 1000 mg.
  • We calculate the tilt with atan2 from how gravity is “spread” over the axes. This is valid only while the module is not moving suddenly.

Step 3: the QMC5883L magnetometer (compass)

The program sets the sensor to continuous measurement and then reads the three axes. The heading is calculated from the X and Y axes: atan2(y, x) gives an angle in radians, which we convert to degrees 0–360.

// QMC5883L – magnetometer (compass) without an extra library
#include <Wire.h>
#include <math.h>

const int QMC = 0x0D;                 // I2C address of the QMC5883L

// offsets from the calibration – 0 at first, later write in your values
float offsetX = 0;
float offsetY = 0;
const float DECLINATION = 5.0;        // magnetic declination in Serbia is about 5° east (check yours)

void writeRegister(byte reg, byte value) {
  Wire.beginTransmission(QMC);
  Wire.write(reg);
  Wire.write(value);
  Wire.endTransmission();
}

int16_t read16() {
  int low  = Wire.read();             // the QMC5883L sends the low byte first
  int high = Wire.read();
  return (int16_t)((high << 8) | low);
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  writeRegister(0x0B, 0x01);          // recommended SET/RESET period value
  writeRegister(0x09, 0x1D);          // continuous mode, 200 Hz, 8 G range
}

void loop() {
  Wire.beginTransmission(QMC);
  Wire.write(0x00);                   // first data register
  Wire.endTransmission(false);
  Wire.requestFrom(QMC, 6, true);

  int16_t x = read16();
  int16_t y = read16();
  int16_t z = read16();

  // heading – valid when the module lies flat
  float heading = atan2(y - offsetY, x - offsetX) * 180.0 / PI;
  heading += DECLINATION;
  if (heading < 0) heading += 360;
  if (heading >= 360) heading -= 360;

  Serial.print("X: "); Serial.print(x);
  Serial.print("  Y: "); Serial.print(y);
  Serial.print("  Z: "); Serial.print(z);
  Serial.print("   heading: "); Serial.println(heading, 0);
  delay(250);
}

Compass calibration

Metal parts on the board and nearby shift the measured values, so a compass without calibration can be off by tens of degrees. The simplest calibration: slowly turn the module in a circle (flat) and remember the smallest and largest X and Y values. The middle between them is the offset that we subtract from the measurement.

// calibration add-on: in loop() after reading x and y
static int16_t minX = 32767, maxX = -32768, minY = 32767, maxY = -32768;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
Serial.print("offsetX = "); Serial.print((maxX + minX) / 2.0);
Serial.print("  offsetY = "); Serial.println((maxY + minY) / 2.0);

When the numbers settle, write them into offsetX and offsetY and remove the add-on. The declination is the difference between magnetic and geographic north; in Serbia it is currently about 5° east (for the exact value for your location, search for a “magnetic declination” calculator).

The compass must lie flat. If you tilt it, the part of the Earth's field that “goes into the ground” (the Z axis) enters X and Y, and the heading becomes wrong. Advanced compasses therefore also use the accelerometer to calculate the tilt and correct the angle (tilt compensation) – the micro:bit does this when you call compass.heading().

The same measurement on the micro:bit

For comparison, this is a MicroPython program that measures the same quantities on the micro:bit:

# micro:bit (MicroPython) – the same measurement on the micro:bit
from microbit import *

compass.calibrate()                   # “fill the screen” game – tilt the micro:bit until all LEDs are lit

while True:
    x = accelerometer.get_x()         # values are already in milli-g (1000 = 1 g)
    y = accelerometer.get_y()
    z = accelerometer.get_z()
    heading = compass.heading()       # 0–359 degrees, 0 = north
    print(x, y, z, heading)           # shown in the REPL / serial console
    sleep(200)

The micro:bit returns acceleration already converted to milli-g, and it has a built-in compass calibration (the “fill the screen” game). On the ESP32 we write these steps ourselves – more work, but this way we see exactly what the sensor does.

Comparison: ESP32 + modules and micro:bit

ESP32 + MPU6050 + QMC5883Lmicro:bit v2
sensorsexternal modules over I2Cbuilt-in LSM303AGR chip (accelerometer + magnetometer)
acceleration unitsraw number, 16384 = 1 g (we convert it)milli-g, 1000 ≈ 1 g (about 1024 on some boards)
default range±2 g (can be ±4, ±8, ±16 g)±2 g (can be changed)
flat, face upZ ≈ +1000 mg (chip up)Z ≈ −1000 mg (display up)
gyroscopeyes (MPU6050)no
gestures (shake, tilt…)we write them ourselves (e.g. a large change in acceleration = shake)built in: accelerometer.was_gesture("shake")
compassatan2 + manual calibration + declinationcompass.calibrate() and compass.heading()
languageC/C++ (Arduino IDE)MicroPython or MakeCode blocks
sending dataWi-Fi, Bluetooth, serial portradio between micro:bits, Bluetooth, serial port

An important difference is the direction of the axes: the same gravity gives a negative Z on the micro:bit and a positive Z on the MPU6050, because the chips are oriented differently. So before comparing, always check: put the device flat and look at the sign of the Z axis.

Experiment: the same motion, two devices

  1. Fix the ESP32 with its modules and the micro:bit next to each other (with tape on a piece of cardboard), so they face the same direction.
  2. Put them flat and write down X, Y, Z from both devices. Convert the ESP32 values to mg and compare. Which axes have the opposite sign?
  3. Tilt the cardboard about 45° forward. What tilt does the ESP32 calculate? What would it be from the micro:bit values, using the same formula?
  4. Turn the cardboard in a circle and compare the compass headings. How big is the difference before and after the calibration?
  5. Shake the cardboard and look at the largest values. Write a simple shake detector for the ESP32: if the total acceleration sqrt(gx² + gy² + gz²) is greater than 2 g – print “shake”.

Check your knowledge

Open the quiz

Quiz: accelerometer and magnetometer

1. What does an accelerometer measure when the sensor is at rest?




2. How many raw MPU6050 units equal 1 g in the ±2 g range?




3. Which ESP32 pins are the default I2C pins?




4. Why must the compass lie flat (without tilt compensation)?