← Back to portfolio Project Case Study

IoT-Based Prolonged Sitting Intervention Device

A two-device IoT system that quietly monitors sitting time and then aggressively nudges the user to stand up using synchronized lights, sound, and vibration.

Role
End-to-end design · Hardware · Firmware · Web
Tech
ESP32, ESP8266, C++ (Arduino), HTTP, HTML/CSS/JS, Ultrasonic sensor, Buzzer, LED, Vibration motor
Focus
Behavior change, IoT, low-friction user experience
Overview diagram showing ESP32 under the desk, ESP8266 on the chair, and the user at a workstation

1. Problem & Project Overview

Research shows that long, uninterrupted periods of sitting are linked to increased mortality, cardiovascular risk, and metabolic issues. Short, frequent breaks can dramatically reduce these risks, but people rarely remember to stand up consistently on their own.

The goal of this project was to build a standalone IoT intervention device that:

  • Monitors sitting time without wearables or a phone app.
  • Uses an ultrasonic sensor under the desk to detect when legs are present.
  • Triggers a visual and auditory alarm after 30 minutes of continuous sitting.
  • Requires at least 60 seconds of standing to “clear” the alert.
  • Optionally adds a chair-mounted device with vibration + LED for stronger cues.
  • Exposes a simple, live web GUI via an ESP32-hosted web server.

2. System Design

The system is split into two low-cost microcontroller devices plus a web GUI:

  • Primary device (server): ESP32 mounted under the desk, with an ultrasonic sensor, LED, and buzzer.
  • Secondary device (client): ESP8266 mounted on the chair, with an LED and vibration motor.
  • Web GUI: A page served directly by the ESP32, accessible from any browser on the same network.
Diagram showing ESP32 under desk and ESP8266 mounted to chair
Physical layout: ESP32 under the desk, ESP8266 attached to the chair, and a browser on the same network viewing the web GUI.
Abstract client-server diagram with HTTP requests between ESP8266 and ESP32
Logical architecture: the ESP32 acts as HTTP server and the ESP8266 + browser act as clients.

2.1 Behavior Logic

The ESP32 implements the core behavior change logic with two timers:

  • Sitting timer: counts how long the ultrasonic sensor detects legs.
  • Standing timer: counts how long the legs have been absent.

The rules are:

  • If legs are detected continuously for 30 minutes → turn on LED + loud buzzer.
  • As soon as the user stands up → buzzer stops, LED stays on.
  • If the user remains standing for 60 seconds → sitting timer resets to 0, LED turns off.
  • If the user sits down again before 60 seconds → standing timer resets, buzzer resumes.

The LED acts as a “red light” while the break is still in progress, making it harder to cheat by briefly standing and sitting right back down.

3. Hardware & Build Process

3.1 ESP32 Server Device

The ESP32 device handles sensing, timing, and hosting the web server. It connects to:

  • An ultrasonic sensor aimed at the user’s legs.
  • A high-brightness LED as a visual cue.
  • A buzzer controlled through a transistor so that the transistor disconnects it from ground until the signal pin goes high, preventing it from continuously sounding whenever it’s wired to ground.
ESP32 server circuit on a breadboard
Early prototyping of the ESP32 circuit on a breadboard: testing the ultrasonic sensor, LED, and buzzer.
Circuit diagram for the ESP32 server device
Circuit diagram prepared before moving the ESP32 circuit to perfboard.
Front view of the soldered ESP32 perfboard
Front of the soldered ESP32 perfboard once the design was finalized.
Back view of the soldered ESP32 perfboard
Back of the perfboard showing hand-routed connections.
ESP32 server mounted underneath a test desk
The ESP32 device mounted under a test desk, tuned so the ultrasonic sensor reliably detects legs.

3.2 ESP8266 Chair Device

The secondary device adds extra cues by mirroring the ESP32’s LED and buzzer states with:

  • An LED on the chair.
  • A small vibration motor powered via a transistor.
  • A battery + charging module so the chair doesn’t need to be plugged in.
ESP8266 client circuit on a breadboard
ESP8266 client device prototyped on a breadboard, originally as a replacement after a faulty board.
Circuit diagram for the ESP8266 chair device
Circuit diagram for the ESP8266 device controlling the LED and vibrating motor.
Front view of the ESP8266 perfboard
Front of the soldered ESP8266 perfboard.
Back view of the ESP8266 perfboard
Back of the ESP8266 perfboard with wiring for the LED, motor, and power components.
Chair with ESP8266 device attached
The ESP8266 device attached to a test chair, providing vibration and additional light cues when it’s time to stand.

4. Firmware & Web Interface

Both microcontrollers are programmed in C++ using the Arduino toolchain. The ESP32 acts as the HTTP server and main brain. The ESP8266 and web browser are clients. For real-world use, the timers can be configured for minutes of sitting and at least 60 seconds of standing — in the code below they are set to shorter windows (5 seconds) for easier testing and demoing.

Screenshot of the ESP32 sitting monitor GUI displaying live data and reset buttons
Web-based GUI served directly from the ESP32, showing live distance, timers, and LED/buzzer state, plus reset buttons.

4.1 ESP32: Sitting Logic & HTTP Server

The ESP32 handles:

  1. Reading the ultrasonic sensor and determining whether the user is sitting.
  2. Maintaining sitting / standing timers and controlling the LED + buzzer.
  3. Acting as an access point and HTTP server that exposes:
    • a /status JSON endpoint with live state
    • a main GUI page with HTML/CSS/JS, and
    • timer reset endpoints at /resetsit and /resetstand.
ESP32 – Full server firmware
#include <WiFi.h>
#include <NetworkClient.h>
#include <WiFiAP.h>

const char *ssid = "ESP32Network";
const char *password = "12345678";

const int trigPin = 4;
const int echoPin = 5;

const int buzzerPin = 23;
const int ledPin = 18;

long inches;
bool sitting;
bool buzzerState = false;
bool ledState = false;

unsigned long sittingTimer = 0;
unsigned long standingTimer = 0;

unsigned long lastUpdate = 0;

NetworkServer server(80);

inline void buzzerOn()  { digitalWrite(buzzerPin, HIGH); buzzerState = true; }
inline void buzzerOff() { digitalWrite(buzzerPin, LOW); buzzerState = false; }
inline void ledOn()  { digitalWrite(ledPin, HIGH); ledState = true; }
inline void ledOff() { digitalWrite(ledPin, LOW); ledState = false; }

void setup() {
  pinMode(trigPin, OUTPUT);
  pinMode(echoPin, INPUT);
  pinMode(buzzerPin, OUTPUT);
  pinMode(ledPin, OUTPUT);

  Serial.begin(115200);
  Serial.println();
  Serial.println("Configuring access point...");

  if (!WiFi.softAP(ssid, password)) {
    log_e("Soft AP creation failed.");
    while (1);
  }
  IPAddress myIP = WiFi.softAPIP();
  Serial.print("AP IP address: ");
  Serial.println(myIP);
  server.begin();

  Serial.println("Server started");
}

void loop() {
  unsigned long now = millis();

  if (now - lastUpdate >= 100){
    lastUpdate = now;

    inches = getDistance();

    sitting = (inches > 0 && inches < 15);

    if(sitting) {
      sittingTimer += 100;
      standingTimer = 0;
    }
    else{
      standingTimer += 100;
    }


    if(standingTimer >= 5000){
      sittingTimer = 0;
    }

    Serial.print("Distance (in): ");
    Serial.print(inches);
    Serial.print(" | Sitting Timer (ms): ");
    Serial.print(sittingTimer);
    Serial.print(" | Standing Timer (ms): ");
    Serial.println(standingTimer);
  }

  if (sittingTimer >= 5000){
    ledOn();
    if(sitting){
      buzzerOn();
    }
    else{
      buzzerOff();
    }
  }
  else{
    ledOff();
    buzzerOff();
  }
  delay(100);

  NetworkClient client = server.accept(); // listen for incoming clients

  if (client) { // if we get a client,
    Serial.println("New Client.");  
    String currentLine = ""; // make a String to hold incoming data from the client
    String reqPath = "";
    bool gotRequestLine = false;

    while (client.connected()) { //loop while the client is connected
      if (client.available()) { //if there are bytes to read from the client
        char c = client.read(); //read a byte
        Serial.write(c); //print it out the serial monitor
        if (c == '\n') { //if the byte is a newline character

          //manual HTTP parsing
          if (!gotRequestLine && currentLine.startsWith("GET ")) { //only parse the first request, only parse GET requests
            int start = 4; //skip the "GET "
            int end = currentLine.indexOf(' ', start); //find the next space starting from the start
            if (end > start) reqPath = currentLine.substring(start, end); //make sure we have a valid path, then get the substring
            gotRequestLine = true;
          }

          if (currentLine.length() == 0) { //end of headers, respond based on path now

            if (reqPath == "/status") {
              sendStatusJSON(client);
            }
            else if (reqPath == "/resetsit"){
              sendResetSittingTimer(client);
            }
            else if (reqPath == "/resetstand"){
              sendResetStandingTimer(client);
            }
            else{ //if the path is anything but status, resetsit, or resetstand just give them the default page
              sendIndexPage(client);
            }
            break; //break out of header loop after response
          }
          else{
            currentLine = ""; //reset the currentLine so that multiple lines do not get jammed together
          }
        }
        else if (c != '\r'){ //we dont care about carriage return, so we skip it, any other character goes into the string
          currentLine += c;
        }
      }
      else{ //no more bytes available
        delay(1);
      }
    }
    // close the connection:
    client.stop();
    Serial.println("Client Disconnected.");
  }
}

//HTML pages
void sendStatusJSON(NetworkClient& client) {
  client.println("HTTP/1.1 200 OK");
  client.println("Content-Type: application/json");
  client.println("Cache-Control: no-cache, no-store, must-revalidate"); //Always get the newest version, do not store it, since data will change
  client.println("Pragma: no-cache"); //Legacy cache control
  client.println("Expires: 0"); //content is already expired, must be fetched again next time
  client.println();
  String json = "{";
  json += "\"distance\":" + String(inches) + ",";
  json += "\"sitting\":" + String(sitting ? "true" : "false") + ",";
  json += "\"sitting_time\":" + String(sittingTimer) + ",";
  json += "\"standing_time\":" + String(standingTimer) + ",";
  json += "\"led_status\":" + String(ledState ? "true" : "false") + ",";
  json += "\"buzzer_status\":" + String(buzzerState ? "true" : "false");
  json += "}";
  client.print(json);
}

void sendResetSittingTimer(NetworkClient& client){ //we will only call this one async, we won't visit it, so we do not need to write actual html for the page
  sittingTimer = 0;
  client.println("HTTP/1.1 200 OK");
  client.println("Content-type: text/plain");
  client.println("Cache-Control: no-cache");
  client.println();
}


void sendResetStandingTimer(NetworkClient& client){
  standingTimer = 0;
  client.println("HTTP/1.1 200 OK");
  client.println("Content-type: text/plain");
  client.println("Cache-Control: no-cache");
  client.println();
}

void sendIndexPage(NetworkClient& client){
  client.println("HTTP/1.1 200 OK");
  client.println("Content-Type: text/html");
  client.println("Cache-Control: no-cache");
  client.println();
  client.println(F( //Flash string literal so we can store this large chunk of static text in flash instead of RAM
  "<html>"
  "<head>"
  "<style>"
  "body{"
  "background: black;"
  "color: white;"
  "display: flex;"
  "flex-direction: column;"
  "justify-content: center;"
  "align-items: center;"
  "min-height: 100vh;"
  "text-align: center;"
"}"

"h3{"
  "background-color: blue;"
  "margin-top: -20px;"
  "margin: 0;"
  "width: 100%;"
"}"

"table{"
  "margin-top: 5px;"
  "border-collapse: collapse;"
"}"

"td,th{"
  "border: 1px, solid;"
  "padding: 3px;"
"}"

"td:nth-child(1){"
  "text-align: left;"
"}"

"td:nth-child(2){"
  "text-align: center;"
"}"

"button{"
  "margin-top: 2px;"
  "height: 30px;"
  "width: 150px;"
"}"
  "</style>"
  "</head>"
  "<body>"
    "<h3>ESP32 Sitting Monitor</h3>"
    "<table>"
        "<tr>"
          "<td>"
            "Distance: "
          "</td>"
          "<td>"
            "<span id='distanceVal'>-</span>"
          "</td>"
          "<td>in</td>"
        "</tr>"

        "<tr>"
          "<td>"
            "Sitting: "
          "</td>"
          "<td>"
            "<span id='sittingVal'>-</span>"
          "</td>"
          "<td></td>"
        "</tr>"

        "<tr>"
          "<td>"
            "Sitting Time: "
          "</td>"
          "<td>"
            "<span id='sittingTimeVal'>-</span>"
          "</td>"
          "<td>ms</td>"
        "</tr>"

        "<tr>"
          "<td>"
            "Standing Time: "
          "</td>"
          "<td>"
            "<span id='standingTimeVal'>-</span>"
          "</td>"
          "<td>ms</td>"
        "</tr>"

        "<tr>"
          "<td>"
            "LED Status: "
          "</td>"
          "<td>"
            "<span id='ledStatusVal'>-</span>"
          "</td>"
          "<td></td>"
        "</tr>"

        "<tr>"
          "<td>"
            "Buzzer Status: "
          "</td>"
          "<td>"
            "<span id='buzzerStatusVal'>-</span>"
          "</td>"
          "<td></td>"
        "</tr>"
    "</table>"
    "<button onclick='resetSit()'>Reset Sitting Time</button>"
    "<button onclick='resetStand()'>Reset Standing Time</button>"
    "<script>"
      "async function poll(){"
        " let r=await fetch('/status');"
        " if(!r.ok)return;" //asynchronously fetch the updated values using our json page
        " let j=await r.json();" //convert the results to json
        " distanceVal.innerText=j.distance;" //update the text values of different html elements with the new values we got from the json page
        " sittingVal.innerText=j.sitting;"
        " sittingTimeVal.innerText=j.sitting_time;"
        " standingTimeVal.innerText=j.standing_time;"
        " ledStatusVal.innerText=j.led_status;"
        " buzzerStatusVal.innerText=j.buzzer_status;"
      "}"
      "setInterval(poll,1000);" //call poll once per second to keep the values current, but not overwhelm the esp32 with requests
      "poll();" //initial poll call
      "async function resetSit(){await fetch('/resetsit');sittingTimeVal.innerText=0;}" //asynchronously calls the other page, which resets the sit timer. Manually set sit timer to 0
      "async function resetStand(){await fetch('/resetstand');standingTimeVal.innerText=0;}"
    "</script>"
  "</body></html>"
  ));
}

long getDistance(){
  digitalWrite(trigPin, LOW);
  delayMicroseconds(2);
  digitalWrite(trigPin, HIGH);
  delayMicroseconds(10);
  digitalWrite(trigPin, LOW);
  long duration = pulseIn(echoPin, HIGH);

  return duration / 2 /74;
}

The JSON endpoint is consumed both by the web GUI (via JavaScript fetch()) and by the secondary ESP8266 device, keeping everything in sync with a single source of truth.

4.2 ESP8266: Chair Client & Haptic Cue

The ESP8266 runs as a simple Wi-Fi client. Once per second, it performs an HTTP GET to http://192.168.4.1/status, parses the JSON, and mirrors the led_status and buzzer_status fields to a chair-mounted LED and vibration motor.

ESP8266 – Full client firmware
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClient.h>

const int ledPin = 5;
const int motorPin = 4;

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(motorPin, OUTPUT);
  Serial.begin(115200);

  Serial.println();
  Serial.println();
  Serial.println();

  for (uint8_t t = 4; t > 0; t--) {
    Serial.printf("[SETUP] WAIT %d...\n", t);
    Serial.flush();
    delay(1000);
  }

  WiFi.mode(WIFI_STA); //this device should behave as a wifi client
  WiFi.begin("ESP32Network", "12345678");
}

void loop() {
  if ((WiFi.status() == WL_CONNECTED)) { //Checking if we are currently connected to a network

    WiFiClient client;
    HTTPClient http;

    if (http.begin(client, "http://192.168.4.1/status")) {

      int httpCode = http.GET(); //sending an HTTP get to the url we specified
      if (httpCode > 0) { //negative values mean communication errors

        if (httpCode == HTTP_CODE_OK) {
          String payload = http.getString();
          Serial.println(payload);

          //find LED status
          int ledIdx = payload.indexOf("\"led_status\":"); //Get the index of the led_status substring
          bool ledStatus = false;
          if (ledIdx >= 0) { // if the number is -1, that means the string was not found
            int start = payload.indexOf(":", ledIdx) + 1;
            int end = payload.indexOf(",", start);
            String val = payload.substring(start, end); //get the substring
            val.trim(); //get rid of random whitespaces or newline
            ledStatus = (val == "true");
          }

          //find buzzer status
          int buzIdx = payload.indexOf("\"buzzer_status\":");
          bool buzzerStatus = false;
          if (buzIdx >= 0) {
            int start = payload.indexOf(":", buzIdx) + 1;
            int end = payload.indexOf("}", start);
            String val = payload.substring(start, end);
            val.trim();
            buzzerStatus = (val == "true");
          }

          Serial.print("LED Status: ");
          Serial.println(ledStatus ? "ON" : "OFF");
          digitalWrite(ledPin, ledStatus);

          Serial.print("Buzzer Status: ");
          Serial.println(buzzerStatus ? "ON" : "OFF");
          digitalWrite(motorPin, buzzerStatus);
        }

      } 
      else { //code that is > 0 and not == 200 means some sort of http error
        Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str()); //convert to C style string that the errorToString expects
      }

      http.end();
    }
  }

  delay(1000);
}

This keeps the chair’s cues perfectly synced with the main device without duplicating any sitting/standing logic on the ESP8266 — it just reacts to the state reported by the ESP32.

5. Testing & Tuning

I gradually built up the system from very small tests:

  • Flashed a basic blink sketch to verify the ESP32 and upload process.
  • Tested GPIO with external LEDs before connecting the buzzer or sensor.
  • Used example web server/client sketches to prove HTTP communication.
  • Integrated the ultrasonic sensor and verified distance readings.
  • Added the buzzer via a transistor and solved interference with capacitors.
  • Introduced the timers and confirmed the 30-minute / 60-second behavior.
  • Moved circuits from breadboard to perfboard and retested everything.
  • Mounted the devices to a test desk and chair and tuned distance thresholds.

6. Demo

The videos below show the device running in a real workspace and the GUI in action.

6.1 Physical Device at the Desk

This demo shows the ESP32 under the desk detecting when I sit, triggering the alarms, and clearing once I’ve stood up long enough.

6.2 Live GUI & Reset Buttons

This demo focuses on the ESP32-hosted GUI: you can see the distance, sitting status, sitting time, standing time, and LED/buzzer states updating live, and watch the Reset Sitting Time button instantly clear the alarm by resetting the sitting timer.

7. Takeaways & Future Work

This project reinforced a few things for me:

  • The importance of clean separation between sensing, state, and presentation (JSON + GUI).
  • How small electrical issues (like the buzzer interfering with the sensor) can be solved with solid hardware fundamentals (transistors, capacitors, power stability).
  • How useful simple HTTP + JSON can be as a protocol between microcontrollers.

Future improvements I’d like to explore:

  • Configurable thresholds (e.g., sitting time, distance) directly from the GUI.
  • Logging sitting/standing history to the cloud for trend visualization.
  • Additional behavior change techniques (gamification, goal setting, more flexible cue types).