In this project we’ll build a web server with the ESP32 to display readings from the MPU-6050 accelerometer and gyroscope sensor. We’ll also create a 3D representation of the sensor orientation on the web browser. The readings are updated automatically using Server-Sent Events and the 3D representation is handled using a JavaScript library called three.js. The ESP32 board will be programmed using the Arduino core.

To build the web server we’ll use the ESPAsyncWebServer library that provides an easy way to build an asynchronous web server and handle Server-Sent Events.
To learn more about Server-Sent Events, read: ESP32 Web Server using Server-Sent Events (Update Sensor Readings Automatically).
Watch the following video for a preview of the project we’ll build.
Before going straight to the project, it’s important to outline what our web server will do, so that it is easier to understand.


To keep our project organized and make it easier to understand, we’ll create four different files to build the web server:

The HTML, CSS and JavaScript files will be uploaded to the ESP32 LittleFS filesystem. To upload files to the ESP32 filesystem, we’ll use the LittleFS Uploader Plugin. Make sure you install it on your Arduino IDE:
If you’re using PlatformIO + VS Code, read this article to learn how to upload files to the ESP32 filesystem:
The MPU-6050 is a module with a 3-axis accelerometer and a 3-axis gyroscope.

The gyroscope measures rotational velocity (rad/s) – this is the change of the angular position over time along the X, Y and Z axis (roll, pitch and yaw). This allows us to determine the orientation of an object.

The accelerometer measures acceleration (rate of change of the velocity of an object). It senses static foces like gravity (9.8m/s2) or dynamic forces like vibrations or movement. The MPU-6050 measures acceleration over the X, Y an Z axis. Ideally, in a static object the acceleration over the Z axis is equal to the gravitational force, and it should be zero on the X and Y axis.
Using the values from the accelerometer, it is possible to calculate the roll and pitch angles using trigonometry, but it is not possible to calculate the yaw.
We can combine the information from both sensors to get accurate information about the sensor orientation.
Learn more about the MPU-6050 sensor: ESP32 with MPU-6050 Accelerometer, Gyroscope and Temperature Sensor.
For this project you need the following parts:
You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!
Wire the ESP32 to the MPU-6050 sensor as shown in the following schematic diagram: connect the SCL pin to GPIO 22 and the SDA pin to GPIO 21.

We’ll program the ESP32 board using Arduino IDE. So, make sure you have the ESP32 add-on installed. Follow the next tutorial:
If you prefer using VSCode + PlatformIO, follow the next tutorial instead:
There are different ways to get readings from the sensor. In this tutorial, we’ll use the Adafruit MPU6050 library. To use this library you also need to install the Adafruit Unified Sensor library and the Adafruit Bus IO Library.
To build the web server, we’ll use the ESPAsyncWebServer and the AsyncTCP libraries. In this example, we’ll send the sensor readings to the browser in JSON format. To make it easier to handle JSON variables, we’ll use the Arduino_JSON library by Arduino.
Here’s the list of libraries you need to install:
Open your Arduino IDE and go to Sketch > Include Library > Manage Libraries. The Library Manager should open. Search for the libraries’ names and install them.
To follow this tutorial you should have the ESP32 Filesystem Uploader plugin installed in your Arduino IDE. If you don’t, follow the next tutorial to install it:
If you’re using VS Code + PlatformIO, follow the next tutorial to learn how to upload files to the ESP32 filesystem:
To build the web server you need four different files. The Arduino sketch, the HTML file, the CSS file and the JavaScript file. The HTML, CSS and JavaScript files should be saved inside a folder called data inside the Arduino sketch folder, as shown below:

You can download all project files:
Create an index.html file with the following content or download all the project files.
<!-- Rui Santos Complete project details at https://RandomNerdTutorials.com/esp32-mpu-6050-web-server/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. --> <!DOCTYPE HTML><html> <head> <title>ESP Web Server</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" href="data:,"> <link rel="stylesheet" type="text/css" href="style.css"> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.2/css/all.css" integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous"> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/107/three.min.js"></script> </head> <body> <div class="topnav"> <h1><i class="far fa-compass"></i> MPU6050 <i class="far fa-compass"></i></h1> </div> <div class="content"> <div class="cards"> <div class="card"> <p class="card-title">GYROSCOPE</p> <p><span class="reading">X: <span id="gyroX"></span> rad</span></p> <p><span class="reading">Y: <span id="gyroY"></span> rad</span></p> <p><span class="reading">Z: <span id="gyroZ"></span> rad</span></p> </div> <div class="card"> <p class="card-title">ACCELEROMETER</p> <p><span class="reading">X: <span id="accX"></span> ms<sup>2</sup></span></p> <p><span class="reading">Y: <span id="accY"></span> ms<sup>2</sup></span></p> <p><span class="reading">Z: <span id="accZ"></span> ms<sup>2</sup></span></p> </div> <div class="card"> <p class="card-title">TEMPERATURE</p> <p><span class="reading"><span id="temp"></span> °C</span></p> <p class="card-title">3D ANIMATION</p> <button id="reset" onclick="resetPosition(this)">RESET POSITION</button> <button id="resetX" onclick="resetPosition(this)">X</button> <button id="resetY" onclick="resetPosition(this)">Y</button> <button id="resetZ" onclick="resetPosition(this)">Z</button> </div> </div> <div class="cube-content"> <div id="3Dcube"></div> </div> </div> <script src="script.js"></script> </body> </html>
The <head> and </head> tags mark the start and end of the head. The head is where you insert data about the HTML document that is not directly visible to the end user, but adds functionalities to the web page – this is called metadata.
The next line gives a title to the web page. In this case, it is set to ESP Web Server. You can change it if you want. The title is exactly what it sounds like: the title of your document, which shows up in your web browser’s title bar.
<title>ESP Web Server</title>
The following meta tag makes your web page responsive. A responsive web design will automatically adjust for different screen sizes and viewports.
<meta name="viewport" content="width=device-width, initial-scale=1">We use the following meta tag because we won’t serve a favicon for our web page in this project.
<link rel="icon" href="data:,">The styles to style the web page are on a separated file called style.css file. So, we must reference the CSS file on the HTML file as follows.
<link rel="stylesheet" type="text/css" href="style.css">Include the Font Awesome website styles to include icons in the web page like the gyroscope icon.
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">Finally, we need to include the three.js library to create the 3D representation of the sensor.
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/107/three.min.js"></script>
The <body> and </body> tags mark the start and end of the body. Everything that goes inside those tags is the visible page content.
There’s a top bar with a heading in the web page. It is a heading 1 and it is placed inside a <div> tag with the class name topnav. Placing your HTML elements between <div> tags, makes them easy to style using CSS.
<div class="topnav"> <h1><i class="far fa-compass"></i> MPU6050 <i class="far fa-compass"></i></h1> </div>
All the other content is placed inside a <div> tag called content.
<div class="content">We use CSS grid layout to display the readings on different aligned boxes (card). Each box corresponds to a grid cell. Grid cells need to be inside a grid container, so the boxes need to be placed inside another <div> tag. This new tag has the classname cards.
<div class="cards">To learn more about CSS grid layout, we recommend this article: A Complete Guide to Grid. Here’s the card for the gyroscope readings:
<div class="card"> <p class="card-title">GYROSCOPE</p> <p><span class="reading">X: <span id="gyroX"></span> rad/s</span></p> <p><span class="reading">Y: <span id="gyroY"></span> rad/s</span></p> <p><span class="reading">Z: <span id="gyroZ"></span> rad/s</span></p> </div>
The card has a title with the name of the card:
<p class="card-title">GYROSCOPE</p>
And three paragraphs to display the gyroscope values on the X, Y and Z axis.
<p><span class="reading">X: <span id="gyroX"></span> rad/s</span></p> <p><span class="reading">Y: <span id="gyroY"></span> rad/s</span></p> <p><span class="reading">Z: <span id="gyroZ"></span> rad/s</span></p>
In each paragraph there’s a <span> tag with a unique id. This is needed so that we can insert the readings on the right place later using JavaScript. Here’s the ids used:
The card to display the accelerometer readings is similar, but with different unique ids for each reading:
<div class="card"> <p class="card-title">ACCELEROMETER</p> <p><span class="reading">X: <span id="accX"></span> ms<sup>2</sup></span></p> <p><span class="reading">Y: <span id="accY"></span> ms<sup>2</sup></span></p> <p><span class="reading">Z: <span id="accZ"></span> ms<sup>2</sup></span></p> </div>
Here’s the ids for the accelerometer readings:
Finally, the following lines display the card for the temperature and the reset buttons.
<div class="card"> <p class="card-title">TEMPERATURE</p> <p><span class="reading"><span id="temp"></span> °C</span></p> <p class="card-title">3D ANIMATION</p> <button id="reset" onclick="resetPosition(this)">RESET POSITION</button> <button id="resetX" onclick="resetPosition(this)">X</button> <button id="resetY" onclick="resetPosition(this)">Y</button> <button id="resetZ" onclick="resetPosition(this)">Z</button> </div>
The unique id for the temperature reading is temp.
Then there are four different buttons that when clicked will call the resetPosition() JavaScript function later on. This function will be responsible for sending a request to the ESP32 informing that we want to reset the position, whether its on all the axis or on an individual axis. Each button has a unique id, so that we know which button was clicked:
We need to create a section to display the 3D representation.
<div class="cube-content"> <div id="3Dcube"></div> </div>
The 3D object will be rendered on the <div> with the 3Dcube id.
Finally, because we’ll use an external JavaScript file with all the functions to handle the HTML elements and create the 3D animation, we need to reference that file (script.js) as follows:
<script src="script.js"></script>
Create a file called style.css with the following content or download all the project files.
This file is responsible for styling the web page.
/* Rui Santos Complete project details at https://RandomNerdTutorials.com/esp32-mpu-6050-web-server/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. */ html { font-family: Arial; display: inline-block; text-align: center; } p { font-size: 1.2rem; } body { margin: 0; } .topnav { overflow: hidden; background-color: #003366; color: #FFD43B; font-size: 1rem; } .content { padding: 20px; } .card { background-color: white; box-shadow: 2px 2px 12px 1px rgba(140,140,140,.5); } .card-title { color:#003366; font-weight: bold; } .cards { max-width: 800px; margin: 0 auto; display: grid; grid-gap: 2rem; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } .reading { font-size: 1.2rem; } .cube-content{ width: 100%; background-color: white; height: 300px; margin: auto; padding-top:2%; } #reset{ border: none; color: #FEFCFB; background-color: #003366; padding: 10px; text-align: center; display: inline-block; font-size: 14px; width: 150px; border-radius: 4px; } #resetX, #resetY, #resetZ{ border: none; color: #FEFCFB; background-color: #003366; padding-top: 10px; padding-bottom: 10px; text-align: center; display: inline-block; font-size: 14px; width: 20px; border-radius: 4px; }
We won’t explain how the CSS for this project works because it is not relevant for the goal of this project.
Create a file called script.js with the following content or download all the project files.
/* Rui Santos Complete project details at https://RandomNerdTutorials.com/esp32-mpu-6050-web-server/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. */ let scene, camera, rendered, cube; function parentWidth(elem) { return elem.parentElement.clientWidth; } function parentHeight(elem) { return elem.parentElement.clientHeight; } function init3D(){ scene = new THREE.Scene(); scene.background = new THREE.Color(0xffffff); camera = new THREE.PerspectiveCamera(75, parentWidth(document.getElementById("3Dcube")) / parentHeight(document.getElementById("3Dcube")), 0.1, 1000); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(parentWidth(document.getElementById("3Dcube")), parentHeight(document.getElementById("3Dcube"))); document.getElementById('3Dcube').appendChild(renderer.domElement); // Create a geometry const geometry = new THREE.BoxGeometry(5, 1, 4); // Materials of each face var cubeMaterials = [ new THREE.MeshBasicMaterial({color:0x03045e}), new THREE.MeshBasicMaterial({color:0x023e8a}), new THREE.MeshBasicMaterial({color:0x0077b6}), new THREE.MeshBasicMaterial({color:0x03045e}), new THREE.MeshBasicMaterial({color:0x023e8a}), new THREE.MeshBasicMaterial({color:0x0077b6}), ]; const material = new THREE.MeshFaceMaterial(cubeMaterials); cube = new THREE.Mesh(geometry, material); scene.add(cube); camera.position.z = 5; renderer.render(scene, camera); } // Resize the 3D object when the browser window changes size function onWindowResize(){ camera.aspect = parentWidth(document.getElementById("3Dcube")) / parentHeight(document.getElementById("3Dcube")); //camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); //renderer.setSize(window.innerWidth, window.innerHeight); renderer.setSize(parentWidth(document.getElementById("3Dcube")), parentHeight(document.getElementById("3Dcube"))); } window.addEventListener('resize', onWindowResize, false); // Create the 3D representation init3D(); // Create events for the sensor readings if (!!window.EventSource) { var source = new EventSource('/events'); source.addEventListener('open', function(e) { console.log("Events Connected"); }, false); source.addEventListener('error', function(e) { if (e.target.readyState != EventSource.OPEN) { console.log("Events Disconnected"); } }, false); source.addEventListener('gyro_readings', function(e) { //console.log("gyro_readings", e.data); var obj = JSON.parse(e.data); document.getElementById("gyroX").innerHTML = obj.gyroX; document.getElementById("gyroY").innerHTML = obj.gyroY; document.getElementById("gyroZ").innerHTML = obj.gyroZ; // Change cube rotation after receiving the readinds cube.rotation.x = obj.gyroY; cube.rotation.z = obj.gyroX; cube.rotation.y = obj.gyroZ; renderer.render(scene, camera); }, false); source.addEventListener('temperature_reading', function(e) { console.log("temperature_reading", e.data); document.getElementById("temp").innerHTML = e.data; }, false); source.addEventListener('accelerometer_readings', function(e) { console.log("accelerometer_readings", e.data); var obj = JSON.parse(e.data); document.getElementById("accX").innerHTML = obj.accX; document.getElementById("accY").innerHTML = obj.accY; document.getElementById("accZ").innerHTML = obj.accZ; }, false); } function resetPosition(element){ var xhr = new XMLHttpRequest(); xhr.open("GET", "/"+element.id, true); console.log(element.id); xhr.send(); }
The init3D() function creates the 3D object. To actually be able to display anything with three.js, we need three things: scene, camera and renderer, so that we can render the scene with camera.
function init3D(){ scene = new THREE.Scene(); scene.background = new THREE.Color(0xffffff); camera = new THREE.PerspectiveCamera(75, parentWidth(document.getElementById("3Dcube")) / parentHeight(document.getElementById("3Dcube")), 0.1, 1000); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(parentWidth(document.getElementById("3Dcube")), parentHeight(document.getElementById("3Dcube"))); document.getElementById('3Dcube').appendChild(renderer.domElement);
To create the 3D object, we need a BoxGeometry. In the box geometry you can set the dimensions of your object. We created the object with the right proportions to resemble the MPU-6050 shape.
const geometry = new THREE.BoxGeometry(5, 1, 4);
Besides the geometry, we also need a material to color the object. There are different ways to color the object. We’ve chosen three different colors for the faces.
// Materials of each face var cubeMaterials = [ new THREE.MeshBasicMaterial({color:0x03045e}), new THREE.MeshBasicMaterial({color:0x023e8a}), new THREE.MeshBasicMaterial({color:0x0077b6}), new THREE.MeshBasicMaterial({color:0x03045e}), new THREE.MeshBasicMaterial({color:0x023e8a}), new THREE.MeshBasicMaterial({color:0x0077b6}), ]; const material = new THREE.MeshFaceMaterial(cubeMaterials);
Finally, create the 3D object, add it to the scene and adjust the camera.
cube = new THREE.Mesh(geometry, material); scene.add(cube); camera.position.z = 5; renderer.render(scene, camera);
We recommend taking a look at this quick three.js tutorial to better understand how it works: Getting Started with three.js – Creating a Scene.
To be able to resize the object when the web browser window changes size, we need to call the onWindowResize() function when the event resize occurs.
// Resize the 3D object when the browser window changes size function onWindowResize(){ camera.aspect = parentWidth(document.getElementById("3Dcube")) / parentHeight(document.getElementById("3Dcube")); //camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); //renderer.setSize(window.innerWidth, window.innerHeight); renderer.setSize(parentWidth(document.getElementById("3Dcube")), parentHeight(document.getElementById("3Dcube"))); } window.addEventListener('resize', onWindowResize, false);
Call the init3D() function to actual create the 3D representation.
init3D();
The ESP32 sends new sensor readings periodically as events to the client (browser). We need to handle what happens when the client receives those events.
In this example, we want to place the readings on the corresponding HTML elements and change the 3D object orientation accordingly.
Create a new EventSource object and specify the URL of the page sending the updates. In our case, it /events.
if (!!window.EventSource) { var source = new EventSource('/events');
Once you’ve instantiated an event source, you can start listening for messages from the server with addEventListener().
These are the default event listeners, as shown here in the AsyncWebServer documentation.
source.addEventListener('open', function(e) { console.log("Events Connected"); }, false); source.addEventListener('error', function(e) { if (e.target.readyState != EventSource.OPEN) { console.log("Events Disconnected"); } }, false);
When new gyroscope readings are available, the ESP32 sends an event gyro_readings to the client. We need to add an event listener for that specific event.
source.addEventListener('gyro_readings', function(e) {
The gyroscope readings are a String in JSON format. For example:
{ "gyroX" : "0.09", "gyroY" : "0.05", "gyroZ": "0.04" }
JavaScript has a built-in function to convert a string, written in JSON format, into native JavaScript objects: JSON.parse().
var obj = JSON.parse(e.data);
The obj variable contains the sensor readings in native JavaScript format. Then, we can access the readings as follows:
The following lines put the received data into the corresponding HTML elements on the web page.
document.getElementById("gyroX").innerHTML = obj.gyroX; document.getElementById("gyroY").innerHTML = obj.gyroY; document.getElementById("gyroZ").innerHTML = obj.gyroZ;
Finally, we need to change the cube rotation according to the received readings, as follows:
cube.rotation.x = obj.gyroY; cube.rotation.z = obj.gyroX; cube.rotation.y = obj.gyroZ; renderer.render(scene, camera);
Note: in our case, the axis are switched as shown previously (rotation X –> gyroY, rotation Z –> gyroX, rotation Y –> gyroZ). You might need to change this depending on your sensor orientation.
For the accelerometer_readings and temperature events, we simply display the data on the HTML page.
source.addEventListener('temperature_reading', function(e) { console.log("temperature_reading", e.data); document.getElementById("temp").innerHTML = e.data; }, false); source.addEventListener('accelerometer_readings', function(e) { console.log("accelerometer_readings", e.data); var obj = JSON.parse(e.data); document.getElementById("accX").innerHTML = obj.accX; document.getElementById("accY").innerHTML = obj.accY; document.getElementById("accZ").innerHTML = obj.accZ; }, false);
Finally, we need to create the resetPosition() function. This function will be called by the reset buttons.
function resetPosition(element){ var xhr = new XMLHttpRequest(); xhr.open("GET", "/"+element.id, true); console.log(element.id); xhr.send(); }
This function simply sends an HTTP request to the server on a different URL depending on the button that was pressed (element.id).
xhr.open("GET", "/"+element.id, true);
Finally, let’s configure the server (ESP32). Copy the following code to the Arduino IDE or download all the project files.
/********* Rui Santos & Sara Santos - Random Nerd Tutorials Complete project details at https://RandomNerdTutorials.com/esp32-mpu-6050-web-server/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. *********/ #include <Arduino.h> #include <WiFi.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> #include <Adafruit_MPU6050.h> #include <Adafruit_Sensor.h> #include <Arduino_JSON.h> #include "LittleFS.h" // Replace with your network credentials const char* ssid = "REPLACE_WITH_YOUR_SSID"; const char* password = "REPLACE_WITH_YOUR_PASSWORD"; // Create AsyncWebServer object on port 80 AsyncWebServer server(80); // Create an Event Source on /events AsyncEventSource events("/events"); // Json Variable to Hold Sensor Readings JSONVar readings; // Timer variables unsigned long lastTime = 0; unsigned long lastTimeTemperature = 0; unsigned long lastTimeAcc = 0; unsigned long gyroDelay = 10; unsigned long temperatureDelay = 1000; unsigned long accelerometerDelay = 200; // Create a sensor object Adafruit_MPU6050 mpu; sensors_event_t a, g, temp; float gyroX, gyroY, gyroZ; float accX, accY, accZ; float temperature; //Gyroscope sensor deviation float gyroXerror = 0.07; float gyroYerror = 0.03; float gyroZerror = 0.01; // Init MPU6050 void initMPU(){ if (!mpu.begin()) { Serial.println("Failed to find MPU6050 chip"); while (1) { delay(10); } } Serial.println("MPU6050 Found!"); } void initLittleFS() { if (!LittleFS.begin()) { Serial.println("An error has occurred while mounting LittleFS"); } Serial.println("LittleFS mounted successfully"); } // Initialize WiFi void initWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); Serial.println(""); Serial.print("Connecting to WiFi..."); while (WiFi.status() != WL_CONNECTED) { Serial.print("."); delay(1000); } Serial.println(""); Serial.println(WiFi.localIP()); } String getGyroReadings(){ mpu.getEvent(&a, &g, &temp); float gyroX_temp = g.gyro.x; if(abs(gyroX_temp) > gyroXerror) { gyroX += gyroX_temp/50.00; } float gyroY_temp = g.gyro.y; if(abs(gyroY_temp) > gyroYerror) { gyroY += gyroY_temp/70.00; } float gyroZ_temp = g.gyro.z; if(abs(gyroZ_temp) > gyroZerror) { gyroZ += gyroZ_temp/90.00; } readings["gyroX"] = String(gyroX); readings["gyroY"] = String(gyroY); readings["gyroZ"] = String(gyroZ); String jsonString = JSON.stringify(readings); return jsonString; } String getAccReadings() { mpu.getEvent(&a, &g, &temp); // Get current acceleration values accX = a.acceleration.x; accY = a.acceleration.y; accZ = a.acceleration.z; readings["accX"] = String(accX); readings["accY"] = String(accY); readings["accZ"] = String(accZ); String accString = JSON.stringify (readings); return accString; } String getTemperature(){ mpu.getEvent(&a, &g, &temp); temperature = temp.temperature; return String(temperature); } void setup() { Serial.begin(115200); initWiFi(); initLittleFS(); initMPU(); // Handle Web Server server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ request->send(LittleFS, "/index.html", "text/html"); }); server.serveStatic("/", LittleFS, "/"); server.on("/reset", HTTP_GET, [](AsyncWebServerRequest *request){ gyroX=0; gyroY=0; gyroZ=0; request->send(200, "text/plain", "OK"); }); server.on("/resetX", HTTP_GET, [](AsyncWebServerRequest *request){ gyroX=0; request->send(200, "text/plain", "OK"); }); server.on("/resetY", HTTP_GET, [](AsyncWebServerRequest *request){ gyroY=0; request->send(200, "text/plain", "OK"); }); server.on("/resetZ", HTTP_GET, [](AsyncWebServerRequest *request){ gyroZ=0; request->send(200, "text/plain", "OK"); }); // Handle Web Server Events events.onConnect([](AsyncEventSourceClient *client){ if(client->lastId()){ Serial.printf("Client reconnected! Last message ID that it got is: %u\n", client->lastId()); } // send event with message "hello!", id current millis // and set reconnect delay to 1 second client->send("hello!", NULL, millis(), 10000); }); server.addHandler(&events); server.begin(); } void loop() { if ((millis() - lastTime) > gyroDelay) { // Send Events to the Web Server with the Sensor Readings events.send(getGyroReadings().c_str(),"gyro_readings",millis()); lastTime = millis(); } if ((millis() - lastTimeAcc) > accelerometerDelay) { // Send Events to the Web Server with the Sensor Readings events.send(getAccReadings().c_str(),"accelerometer_readings",millis()); lastTimeAcc = millis(); } if ((millis() - lastTimeTemperature) > temperatureDelay) { // Send Events to the Web Server with the Sensor Readings events.send(getTemperature().c_str(),"temperature_reading",millis()); lastTimeTemperature = millis(); } }
Before uploading the code, make sure you insert your network credentials on the following variables:
// Replace with your network credentials const char* ssid = "REPLACE_WITH_YOUR_SSID"; const char* password = "REPLACE_WITH_YOUR_PASSWORD";
Continue reading to learn how the code works or proceed to the next section.
First, import all the required libraries for this project:
#include <Arduino.h> #include <WiFi.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> #include <Adafruit_MPU6050.h> #include <Adafruit_Sensor.h> #include <Arduino_JSON.h> #include "LittleFS.h"
Insert your network credentials in the following variables:
// Replace with your network credentials const char* ssid = "REPLACE_WITH_YOUR_SSID"; const char* password = "REPLACE_WITH_YOUR_PASSWORD";
Create an AsyncWebServer object on port 80.
AsyncWebServer server(80);
The following line creates a new event source on /events.
AsyncEventSource events("/events");
The readings variable is a JSON variable to hold the sensor readings in JSON format.
JSONVar readings;In this project, we’ll send the gyroscope readings every 10 milliseconds, the accelerometer readings every 200 milliseconds, and the temperature readings every second. So, we need to create auxiliary timer variables for each reading. You can change the delay times if you want.
// Timer variables unsigned long lastTime = 0; unsigned long lastTimeTemperature = 0; unsigned long lastTimeAcc = 0; unsigned long gyroDelay = 10; unsigned long temperatureDelay = 1000; unsigned long accelerometerDelay = 200;
Create an Adafruit_MPU5060 object called mpu, create events for the sensor readings and variables to hold the readings.
// Create a sensor object Adafruit_MPU6050 mpu; sensors_event_t a, g, temp; float gyroX, gyroY, gyroZ; float accX, accY, accZ; float temperature;
Adjust he gyroscope sensor offset on all axis.
//Gyroscope sensor deviation float gyroXerror = 0.07; float gyroYerror = 0.03; float gyroZerror = 0.01;
To get the sensor offset, go to File > Examples > Adafruit MPU6050 > basic_readings. With the sensor in a static position, check the gyroscope X, Y, and Z values. Then, add those values to the gyroXerror, gyroYerror and gyroZerror variables.
The initMPU() function initializes te MPU-6050 sensor.
// Init MPU6050 void initMPU(){ if (!mpu.begin()) { Serial.println("Failed to find MPU6050 chip"); while (1) { delay(10); } } Serial.println("MPU6050 Found!"); }
The initLittleFS() function initializes the ESP32 filesystem so that we’re able to get access to the files saved on LittleFS (index.html, style.css and script.js).
void initLittleFS() { if (!LittleFS.begin()) { Serial.println("An error has occurred while mounting LittleFS"); } Serial.println("LittleFSmounted successfully"); }
The initWiFi() function connects the ESP32 to your local network.
// Initialize WiFi void initWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); Serial.print("Connecting to WiFi .."); while (WiFi.status() != WL_CONNECTED) { Serial.print('.'); delay(1000); } Serial.println(WiFi.localIP()); }
The getGyroReadings() function gets new gyroscope readings and returns the current angular orientation on the X, Y an Z axis as a JSON string.
The gyroscope returns the current angular velocity. The angular velocity is measured in rad/s. To determine the current position of an object, we need to multiply the angular velocity by the elapsed time (10 milliseconds) and add it to the previous position.
current angle (rad) = last angle (rad) + angular velocity (rad/s) * time(s)
The gyroX_temp variable temporarily holds the current gyroscope X value.
float gyroX_temp = g.gyro.x;
To prevent small oscillations of the sensor (see Gyroscope Offset), we first check if the values from the sensor are greater than the offset.
if(abs(gyroX_temp) > gyroXerror) {
If the current value is greater than the offset value, we consider that we have a valid reading. So, we can apply the previous formula to get the current sensor’s angular position (gyroX).
gyroX += gyroX_temp / 50.0;
Note: theoretically, we should multiply the current angular velocity by the elapsed time (10 milliseconds = 0.01 seconds (gyroDelay)) – or divide by 100. However, after some experiments, we found out that the sensor responds better if we divide by 50.0 instead. Your sensor may be different and you may need to adjust the value.
We follow a similar procedure to get the Y and Z values.
float gyroX_temp = g.gyro.x; if(abs(gyroX_temp) > gyroXerror) { gyroX += gyroX_temp/50.00; } float gyroY_temp = g.gyro.y; if(abs(gyroY_temp) > gyroYerror) { gyroY += gyroY_temp/70.00; } float gyroZ_temp = g.gyro.z; if(abs(gyroZ_temp) > gyroZerror) { gyroZ += gyroZ_temp/90.00; }
Finally, we concatenate the readings in a JSON variable (readings) and return a JSON string (jsonString).
readings["gyroX"] = String(gyroX); readings["gyroY"] = String(gyroY); readings["gyroZ"] = String(gyroZ); String jsonString = JSON.stringify(readings); return jsonString;
The getAccReadings() function returns the accelerometer readings.
String getAccReadings(){ mpu.getEvent(&a, &g, &temp); // Get current acceleration values accX = a.acceleration.x; accY = a.acceleration.y; accZ = a.acceleration.z; readings["accX"] = String(accX); readings["accY"] = String(accY); readings["accZ"] = String(accZ); String accString = JSON.stringify (readings); return accString; }
The getTemperature() function returns the current temperature reading.
String getTemperature(){ mpu.getEvent(&a, &g, &temp); temperature = temp.temperature; return String(temperature); }
In the setup(), initialize the Serial Monitor, Wi-Fi, LittleFS and the MPU sensor.
void setup() { Serial.begin(115200); initWiFi(); initLittleFS(); initMPU();
When the ESP32 receives a request on the root URL, we want to send a response with the HTML file (index.html) content that is stored in LittleFS.
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ request->send(LittleFS, "/index.html", "text/html"); });
The first argument of the send() function is the filesystem where the files are saved, in this case it is saved in LittleFS. The second argument is the path where the file is located. The third argument refers to the content type (HTML text).
In your HTML file, you reference the style.css and script.js files. So, when the HTML file loads on your browser, it will make a request for those CSS and JavaScript files. These are static files saved on the same directory (LittleFS). So, we can simply add the following line to serve static files in a directory when requested by the root URL. It serves the CSS and JavaScript files automatically.
server.serveStatic("/", LittleFS, "/");
We also need to handle what happens when the reset buttons are pressed. When you press the RESET POSITION button, the ESP32 receives a request on the /reset path. When that happens, we simply set the gyroX, gyroY and gyroZ variables to zero to restore the sensor initial position.
server.on("/reset", HTTP_GET, [](AsyncWebServerRequest *request){ gyroX=0; gyroY=0; gyroZ=0; request->send(200, "text/plain", "OK"); });
We send an “OK” response to indicate the request succeeded.
We follow a similar procedure for the other requests (X, Y and Z buttons).
server.on("/resetX", HTTP_GET, [](AsyncWebServerRequest *request){ gyroX=0; request->send(200, "text/plain", "OK"); }); server.on("/resetY", HTTP_GET, [](AsyncWebServerRequest *request){ gyroY=0; request->send(200, "text/plain", "OK"); }); server.on("/resetZ", HTTP_GET, [](AsyncWebServerRequest *request){ gyroZ=0; request->send(200, "text/plain", "OK"); });
The following lines setup the event source on the server.
// Handle Web Server Events events.onConnect([](AsyncEventSourceClient *client){ if(client->lastId()){ Serial.printf("Client reconnected! Last message ID that it got is: %u\n", client->lastId()); } // send event with message "hello!", id current millis // and set reconnect delay to 1 second client->send("hello!", NULL, millis(), 10000); }); server.addHandler(&events);
Finally, start the server.
server.begin();
In the loop(), we’ll send events to the client with the new sensor readings.
The following lines send the gyroscope readings on the gyro_readings event every 10 milliseconds (gyroDelay).
if ((millis() - lastTime) > gyroDelay) { // Send Events to the Web Server with the Sensor Readings events.send(getGyroReadings().c_str(),"gyro_readings",millis()); lastTime = millis(); }
Use the send() method on the events object and pass as argument the content you want to send and the name of the event. In this case, we want to send the JSON string returned by the getGyroReadings() function. The send() method accepts a variable of type char, so we need to use the c_str() method to convert the variable. The name of the events is gyro_readings.
We follow a similar procedure for the accelerometer readings, but we use a different event (accelerometer_readings) and a different delay time (accelerometerDelay):
if ((millis() - lastTimeAcc) > accelerometerDelay) { // Send Events to the Web Server with the Sensor Readings events.send(getAccReadings().c_str(),"accelerometer_readings",millis()); lastTimeAcc = millis(); }
And finally, for the temperature readings:
if ((millis() - lastTimeTemperature) > temperatureDelay) { // Send Events to the Web Server with the Sensor Readings events.send(getTemperature().c_str(),"temperature_reading",millis()); lastTimeTemperature = millis(); }
After inserting your network credentials, save the code. Go to Sketch > Show Sketch Folder, and create a folder called data.

Inside that folder you should save the HTML, CSS and JavaScript files.
Then, upload the code to your ESP32 board. Make sure you have the right board and COM port selected. Also, make sure you’ve added your networks credentials to the code.
After uploading the code, you need to upload the files. In the Arduino IDE, press [Ctrl] + [Shift] + [P] on Windows or [⌘] + [Shift] + [P] on MacOS to open the command palette. Search for the Upload LittleFS to Pico/ESP8266/ESP32 command and click on it.

If you don’t have this option is because you didn’t install the filesystem uploader plugin. Check this tutorial.
Important: make sure the Serial Monitor is closed before uploading to the filesystem. Otherwise, the upload will fail.
When everything is successfully uploaded, open the Serial Monitor at a baud rate of 115200. Press the ESP32 EN/RST button, and it should print the ESP32 IP address.

Open your browser and type the ESP32 IP address. You should get access to the web page that shows the sensor readings.
Move the sensor and see the readings changing as well as the 3D object on the browser.

Note: the sensor drifts a bit on the X axis, despite some adjustments in the code. Many of our readers commented that that’s normal for this kind of MCUs. To reduce the drifting, some readers suggested using a complementary filter or a kalman filter.
Copyright ©2025. All Rights Reserved Emblab THE RAVE INNOVATION