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.
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.
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.
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.
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.
4.1 ESP32: Sitting Logic & HTTP Server
The ESP32 handles:
- Reading the ultrasonic sensor and determining whether the user is sitting.
- Maintaining sitting / standing timers and controlling the LED + buzzer.
-
Acting as an access point and HTTP server that exposes:
- a
/statusJSON endpoint with live state - a main GUI page with HTML/CSS/JS, and
-
timer reset endpoints at
/resetsitand/resetstand.
- a
#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.
#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).