Why I made this
August 2025 was a month where I discovered an app named "Footie Addicts" which made me play numerous games of football in this month. What I noticed was that I would be experiencing crazy amounts of pain after (partially due to not stretching) but I also figured that my running form was completely off and the balls of my feet would hurt. As a result, I decided to make a low cost tool to help people analyse their own running patterns and form indirectly without the need of a camera.
The Plan
I will use a LIS3DH accelerometer and try to process the signals afterwards. As a rough guide, I wanted to alert the user of 3 main factors that lead to inefficient energy use:
1- Heavy footing which is due improper striking technique or overstriding 2- Swaying side to side 3- Bouncing / jumping when running
These factors were decided due to the video below.
View Full Reference Video here
My plan was to have an OLED display to tell the user when one or more of these factors are being performed. My next plan was then to have a way of graphing the users motion for analysis afterwards.
Circuit Design
I decided to use the XIAOESP32C3 due to its compact nature making it suitable for a wearable device. By connecting the XIAOESP32C3 to the LIS3DH to an OLED screen, I could display words to help the runners form during exercise. The circuit is shown below. I learnt here that multiple devices can be placed onto the SDA and SCL pins as these are shared lines that all devices connect to in parallel which greatly simplified wiring for me since both the accelerometer and the LIS3DH could be attached to the same pins.

Code and testing
The following code shows the logic behind my ideas.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_LIS3DH.h>
#include <Adafruit_Sensor.h>
#include <WiFi.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define SDA_PIN 6
#define SCL_PIN 7
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
Adafruit_LIS3DH lis = Adafruit_LIS3DH();
void setup() {
Serial.begin(115200);
// Init I2C with default pins
Wire.begin(SDA_PIN, SCL_PIN);
// Initiate OLED
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
for (;;); // stop here if display fails
}
// Test message to confirm OLED works
display.clearDisplay();
display.setTextSize(1.5);
display.setTextColor(WHITE);
display.setCursor(0, 0);
display.println("OLED Ready!");
display.display();
delay(1000);
// Init LIS3DH
if (!lis.begin(0x18)) {
while (1) delay(10);
}
Serial.println("time_ms,ax,ay,az,totalAccel");
lis.setRange(LIS3DH_RANGE_4_G);
}
void loop() {
sensors_event_t event;
lis.getEvent(&event);
float ax = event.acceleration.x;
float ay = event.acceleration.y;
float az = event.acceleration.z;
float totalAccel = sqrt(ax * ax + ay * ay + az * az);
// dynamically test instead of else statements
String message = "" ;
bool heavy = (totalAccel > 1.0);
bool bobbing = (abs(ay) > 1.0);
bool swaying = (abs(ax) > 1.0);
if (heavy) message += "Footing\n";
if (bobbing) message += "Bobbing\n";
if (swaying) message += "Swaying\n";
if (message == "") message = "Nice!";
display.clearDisplay();
display.setTextSize(2);
display.setCursor(0, 10);
display.println(message);
display.display();
// Print CSV-friendly data
Serial.print(millis()); // time in ms
Serial.print(",");
Serial.print(ax, 4); // X
Serial.print(",");
Serial.print(ay, 4); // Y
Serial.print(",");
Serial.print(az, 4); // Z
Serial.print(",");
Serial.println(totalAccel, 4); // total acceleration
delay(1000);
}
The one thing that really helped optimise the code was the dynamic condition testing. Pointed out by a friend, I had a series of if and elif statements before but this meant that I had to list out every permutation possible which is not ideal. This really showed me how taking a step back to think and research a problem properly before utilising the only knowledge I had on a topic could prove useful to improve code.
The messages printed on the serial monitor are shown below.

By setting all thresholds of the sensor to a small value, I was also able to verify that my OLED worked.

A massive oversight I had was my sampling rate was set to one sample a second which was 1 Hz. I had assumed that runners would average a step every second but upon research, 3 steps were taken which meant that my graphs would not be accurate and changes in states will be highly exaggerated (which is soon when I analyse the data).
When testing I had another problem - I had no wireless method of transmitting data from the device on the user to the serial monitor on my laptop. As a result, I had to use a USB Bus terminal app to record the data and below is the setup. I originally planned for the device to be setup around the chest area but the elastic band I attached to the user kept slipping leading me to choose the abdominal area to measure from for ease.

Changing the plan
At this point, I believed I had a decent design but I realised how disconnected everything about this project felt. I did not like how plotting and tracking the data were seperate instances and from a user perspective, switching to python with the csv would not make sense.
As a result, I chose to use Blynk IO which allows me to create a platform where users can track their performance on a single platform.
Implementing Bynk IO
With Blynk IO, I felt that the need to display on another OLED screen would take too much memory as opposed to displaying directly onto a phone app. As a result I chose to remove the OLED. Although, this meant that users could not see what was wrong with their form during exercise, it would provide a better and more fluid experience. This now requires new code:
// ===== Blynk + WiFi credentials =====
#define BLYNK_TEMPLATE_ID "Template ID"
#define BLYNK_TEMPLATE_NAME "Temp name"
#define BLYNK_AUTH_TOKEN "Auth Token"
#define WIFI_SSID "SSID"
#define WIFI_PASS "pw"
// ===== Includes (no OLED) =====
#include <Wire.h>
#include <Adafruit_LIS3DH.h>
#include <Adafruit_Sensor.h>
#include <WiFi.h>
#include <BlynkSimpleEsp32.h>
#include <math.h>
// ===== I2C pins =====
#define SDA_PIN 6
#define SCL_PIN 7
// ===== Globals =====
Adafruit_LIS3DH lis = Adafruit_LIS3DH();
BlynkTimer timer;
float ax = 0, ay = 0, az = 0, totalAccel = 0;
// Timer callback: read sensor and push to Blynk
void myTimer() {
sensors_event_t event;
lis.getEvent(&event); // m/s^2
ax = event.acceleration.x;
ay = event.acceleration.y;
az = event.acceleration.z;
totalAccel = sqrtf(ax*ax + ay*ay + az*az);
// Send X, Y, and Total (V0, V1, V2) — delete any you don't need
Blynk.virtualWrite(V0, abs(ax));
Blynk.virtualWrite(V1, abs(ay));
Blynk.virtualWrite(V2, totalAccel);
}
void setup() {
Serial.begin(115200); // <-- semicolon fixed
// I2C + LIS3DH init
Wire.begin(SDA_PIN, SCL_PIN);
if (!lis.begin(0x18)) { // try both common addresses
if (!lis.begin(0x19)) {
Serial.println("LIS3DH not found");
while (1) { delay(10); }
}
}
lis.setRange(LIS3DH_RANGE_4_G); // 2/4/8/16 G
lis.setDataRate(LIS3DH_DATARATE_100_HZ);
// Blynk connect
Blynk.begin(BLYNK_AUTH_TOKEN, WIFI_SSID, WIFI_PASS);
// Send every 250 ms (4 Hz). Use 1000 ms if you prefer less bandwidth.
timer.setInterval(250L, myTimer);
}
void loop() {
Blynk.run();
timer.run();
}
The code removes the need for printing onto the serial monitor which consumes memory and sends directly to the Blynk cloud to be plotted.
I then created a very simple UI web app for the user to look at to track their data - either as they run or after for more holistic analysis.
The simple UI created is seen below:
