Build a machine that notices when a fan or washing machine sounds "wrong"

An MPU-6050 learns what a fan's or washing machine's normal vibration feels like, then raises the alarm when something changes. This is anomaly detection, the AI that factories use to catch broken machines early.
  • Level:Advanced
  • Duration: 1 h 10 min
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Build a machine that notices when a fan or washing machine sounds "wrong" – EEMENTOR STEM™ lesson

A machine that is about to break often feels different first: a fan with a bent blade wobbles, a washing machine with a stone in the drum rattles. Factories use AI to notice those changes before anything breaks. It's called anomaly detection. The clever part is that you don't have to show the AI what "broken" looks like. You only show it what normal looks like, and it raises the alarm at anything different. In this lesson the MPU-6050 learns the vibration of a running fan for 30 seconds. Then tape a coin to one blade, and watch the anomaly score jump.

What you'll learn

  • How to describe a vibration with a few features
  • How a model can learn "normal" from examples of normal only
  • How to turn a difference into a score with averages and standard deviations
  • Why an alarm should wait for several unusual readings in a row

What you need

  • ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
  • GY-521 MPU-6050 module (45-in-1 Sensor Kit)
  • Green LED, red LED and 2 resistors, 220 Ω (ESP32 IoT Starter Kit)
  • Passive buzzer (ESP32 IoT Starter Kit)
  • A desk fan, or the kit's DC motor with a fan blade
  • A coin and some tape, to make the fan "wrong"
  • A 2.4 GHz Wi-Fi network
  • The printed parts from the 3D printing box below (vibration pod), or the cardboard version described there
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)

Wiring

Breadboard wiring diagram: Build a machine that notices when a fan or washing machine sounds
Breadboard view. Check each connection against the wiring table below.
From To
MPU-6050 VCC and GND 3V3 and GND
MPU-6050 SCL ESP32 GPIO 22
MPU-6050 SDA ESP32 GPIO 21
ESP32 GPIO 25 220 Ω resistor, then the green LED's long leg
ESP32 GPIO 26 220 Ω resistor, then the red LED's long leg
Both LED short legs GND
Passive buzzer + ESP32 GPIO 33
Passive buzzer − GND

Safety: only fix the pod to the outside of a machine, never near moving parts, and ask an adult before you touch a washing machine or anything that plugs into the mains.

The sketch

Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).

#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>

const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const int MPU = 0x68;
const int GREEN_PIN = 25, RED_PIN = 26, BUZZER_PIN = 33;
const int WINDOW = 100;              // 100 readings = 1 second
const int LEARN_WINDOWS = 30;        // learn "normal" for 30 seconds
const float LIMIT = 4.0;             // score above this is unusual
const int FEATURES = 3;

WebServer server(80);
float window[WINDOW];
int filled = 0;
unsigned long nextRead = 0;
float sum[FEATURES], sumSq[FEATURES], mean[FEATURES], spread[FEATURES];
int learned = 0;                     // windows learned so far
bool learning = false;
float score = 0;
int unusualInARow = 0;
String history = "";

// Acceleration in m/s² on all three axes (1 g = 9.81 m/s²)
void readAccel(float& x, float& y, float& z) {
  Wire.beginTransmission(MPU);
  Wire.write(0x3B);
  Wire.endTransmission(false);
  Wire.requestFrom(MPU, 6);
  int16_t ax = Wire.read() << 8 | Wire.read();
  int16_t ay = Wire.read() << 8 | Wire.read();
  int16_t az = Wire.read() << 8 | Wire.read();
  x = ax / 16384.0 * 9.81;
  y = ay / 16384.0 * 9.81;
  z = az / 16384.0 * 9.81;
}

// Three features describe one second of vibration
void features(float f[FEATURES]) {
  float avg = 0;
  for (int i = 0; i < WINDOW; i++) avg += window[i];
  avg /= WINDOW;
  float energy = 0, peak = 0;
  int crossings = 0;
  for (int i = 0; i < WINDOW; i++) {
    float v = window[i] - avg;                    // the shaking, without gravity
    energy += v * v;
    peak = fmaxf(peak, fabsf(v));
    if (i > 0 && (window[i - 1] - avg) * v < 0) crossings++;
  }
  f[0] = sqrt(energy / WINDOW);                   // how strong (RMS)
  f[1] = peak;                                    // the biggest jolt
  f[2] = crossings;                               // how fast it wiggles
}

void analyse() {
  float f[FEATURES];
  features(f);
  if (learning) {                                 // collect "normal"
    for (int k = 0; k < FEATURES; k++) { sum[k] += f[k]; sumSq[k] += f[k] * f[k]; }
    learned++;
    if (learned == LEARN_WINDOWS) {
      for (int k = 0; k < FEATURES; k++) {
        mean[k] = sum[k] / learned;
        spread[k] = sqrtf(fmaxf(sumSq[k] / learned - mean[k] * mean[k], 0.0f));
        spread[k] = fmaxf(spread[k], mean[k] * 0.05f + 0.01f);   // never divide by almost nothing
      }
      learning = false;
      Serial.println("Learned what normal feels like.");
    }
    return;
  }
  if (learned < LEARN_WINDOWS) return;
  float total = 0;                                // how many "spreads" away from normal?
  for (int k = 0; k < FEATURES; k++) {
    float z = (f[k] - mean[k]) / spread[k];
    total += z * z;
  }
  score = sqrt(total / FEATURES);
  unusualInARow = score > LIMIT ? unusualInARow + 1 : 0;
  bool alarm = unusualInARow >= 3;                // three unusual seconds in a row
  digitalWrite(GREEN_PIN, !alarm);
  digitalWrite(RED_PIN, alarm);
  if (alarm) tone(BUZZER_PIN, 1200, 200);
  history += String(score, 1) + ",";
  if (history.length() > 600) history = history.substring(history.indexOf(',') + 1);
  Serial.printf("score %.1f%s\n", score, alarm ? "  ANOMALY!" : "");
}

void startLearning() {
  for (int k = 0; k < FEATURES; k++) sum[k] = sumSq[k] = 0;
  learned = 0;
  learning = true;
  unusualInARow = 0;
  digitalWrite(RED_PIN, LOW);
}

void handleState() {
  String state = learning ? "learning" : (learned < LEARN_WINDOWS ? "untrained" : (unusualInARow >= 3 ? "anomaly" : "normal"));
  server.send(200, "application/json", "{\"state\":\"" + state + "\",\"progress\":" + String(learned * 100 / LEARN_WINDOWS) +
              ",\"score\":" + String(score, 2) + ",\"limit\":" + String(LIMIT, 1) + ",\"history\":[" + history + "0]}");
}

const char PAGE[] = R"html(<!doctype html><meta name=viewport content='width=device-width'><title>Machine watch</title>
<style>body{font-family:sans-serif;max-width:600px;margin:20px auto;padding:0 16px;text-align:center}#s{font-size:40px;font-weight:bold;padding:24px;border-radius:18px;color:#fff;background:#999}
svg{width:100%;height:160px;background:#F6F8FC;border-radius:12px;margin-top:16px}button{padding:12px 18px;border:0;border-radius:12px;background:#1B3A7B;color:#fff;font-size:16px;margin-top:16px}</style>
<h1>Machine watch</h1><div id=s>...</div><p id=t></p><svg id=c viewBox='0 0 300 100' preserveAspectRatio=none></svg><button onclick="fetch('/learn')">Learn normal (30 s)</button>
<script>
const COLOURS = {normal: '#23A07A', anomaly: '#DE544A', learning: '#F2A93B', untrained: '#999'};
async function tick() {
  try {
    const d = await (await fetch('/state')).json();
    const s = document.getElementById('s');
    s.textContent = d.state == 'learning' ? 'Learning... ' + d.progress + ' %' : d.state;
    s.style.background = COLOURS[d.state];
    document.getElementById('t').textContent = 'Anomaly score: ' + d.score + ' (alarm above ' + d.limit + ')';
    const h = d.history.slice(-100), max = Math.max(d.limit * 2, ...h);
    const y = v => 100 - v / max * 100;
    document.getElementById('c').innerHTML = '<line x1=0 x2=300 y1=' + y(d.limit) + ' y2=' + y(d.limit) + ' stroke=#DE544A stroke-dasharray=4 />' +
      '<polyline fill=none stroke=#1B3A7B stroke-width=2 points="' + h.map((v, i) => (i * 3) + ',' + y(v)).join(' ') + '"/>';
  } catch (e) {}
}
tick(); setInterval(tick, 1000);
</script>)html";

void setup() {
  Serial.begin(115200);
  pinMode(GREEN_PIN, OUTPUT);
  pinMode(RED_PIN, OUTPUT);

  Wire.begin(21, 22);
  Wire.beginTransmission(MPU);
  Wire.write(0x6B);                 // wake the MPU-6050 up
  Wire.write(0);
  Wire.endTransmission();

  WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
  Serial.print("Connecting to Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.print(" connected. Open http://");
  Serial.println(WiFi.localIP());
  server.on("/", []() { server.send(200, "text/html", PAGE); });
  server.on("/state", handleState);
  server.on("/learn", []() { startLearning(); server.send(200, "text/plain", "ok"); });
  server.begin();
}

void loop() {
  server.handleClient();
  if ((long)(micros() - nextRead) < 0) return;
  nextRead = micros() + 10000;                    // 100 readings a second
  float x, y, z;
  readAccel(x, y, z);
  window[filled++] = sqrt(x * x + y * y + z * z);
  if (filled == WINDOW) {
    filled = 0;
    analyse();
  }
}

Try it

Open the Serial Monitor at 115200 baud: after connecting, the ESP32 prints its address, such as http://192.168.1.42. Open it in a browser on a phone or computer in the same Wi-Fi.

  1. Fix the pod to a running fan and press Learn normal. Don't touch anything for 30 seconds.
  2. The page turns green: normal. The score stays low, mostly below 2.
  3. Switch the fan off: the vibration changes, and after three seconds the page turns red, the red LED lights and the buzzer beeps. Switch it on again: back to green.
  4. Now tape a coin to one blade. The fan wobbles a little more. Can the detector tell, even though you can hardly feel it?

How it works

One second of vibration is 100 numbers, too many to compare directly. So the sketch boils every second down to three features: how strong the shaking is on average (the RMS), the biggest single jolt, and how often the signal crosses its middle line, which tells how fast it wiggles. A fan with a coin on a blade shakes a bit harder and at the same speed as the fan turns.

While learning, the sketch collects 30 seconds of normal features and works out each one's average and standard deviation, how much it usually varies. Later, every new feature is measured in standard deviations from normal, called a z-score. Combining the three z-scores gives the anomaly score: around 1 is perfectly normal, above 4 is very unusual.

A single odd second can happen, like someone bumping the table. The alarm only goes off after three unusual seconds in a row, which avoids false alarms. Real factory systems balance the same thing: catch problems early, but don't cry wolf.

Try this next

  • Send an ntfy alert to your phone when an anomaly lasts more than ten seconds.
  • Learn two normal modes, like fan speed 1 and 2, and tell which one is running.
  • Fix it to a washing machine and record when the spin cycle starts.

Troubleshooting

  • It always says anomaly: learn again while the machine runs normally, and check that the pod is tight.
  • It never says anomaly: the change is too small. Lower LIMIT to 3, or use a bigger coin.
  • The score jumps when you walk past: put the machine on a firm surface, or raise LIMIT.
  • It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.
  • A printed part is too tight or too loose: printers differ by a few tenths of a millimetre. Sand or file a tight hole, and fix a loose part with a drop of hot glue or a strip of tape.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit