How to Build an ESP32 Desk Presence Busy Light
Motion sensing, manual busy mode, OLED status, and a colour LED ring
Updated

What you'll build
This guide walks you through building a small ESP32 desk presence light that makes your workspace status obvious at a glance. A PIR motion sensor marks the desk as recently active, a tactile button toggles manual busy mode, an SSD1306 OLED shows the current state in plain text, and a WS2812B LED ring changes colour: green for free, orange for recently active, and red when you have deliberately set focus mode.
The build is intentionally practical rather than flashy. You will wire a common PIR module to a digital input, use the ESP32 internal pull-up for the button, share the OLED over I2C, and drive the addressable LED ring from one data pin. The firmware keeps the motion state alive for a short window after movement, debounces the button, and updates both the display and lights without needing Wi-Fi, accounts, or a phone app.
By the end you will have a useful desk sign for shared studios, home offices, livestream setups, or classrooms where people need a simple signal before interrupting you. The code is easy to adapt: change the timeout, swap the LED colours, add a quiet mode for evenings, or send the state over MQTT later once the physical version works reliably.
Wiring diagram
Wiring diagram
Components needed
| Component | Type | Qty | Buy |
|---|---|---|---|
| HC-SR501 PIR Motion Sensor | sensor | 1 | €1.85 |
| Tactile Push Button | input | 1 | €8.30 |
| WS2812B LED Ring | actuator | 1 | |
| SSD1306 OLED Display | display | 1 | €4.30 |
Prices and availability are indicative and may have been updated by the supplier. Schematik may earn a commission from purchases made through affiliate links.
Assembly
Wire the shared power rails
Connect each module VCC to the ESP32 3V3 rail where supported, and connect all grounds together.
Connect the PIR sensor
Wire PIR OUT to GPIO 27, then place the sensor so it sees the desk rather than the whole room.
Add the busy button
Connect the button signal side to GPIO 26 and the other side to ground. The firmware uses the internal pull-up.
Connect the LED ring and OLED
Wire the LED ring DIN to GPIO 4. Connect the OLED SDA to GPIO 21 and SCL to GPIO 22.
Upload and tune
Upload the sketch, check the three states, then adjust occupiedHoldMs if the status clears too quickly or too slowly.
Pin assignments
| Pin | Connection | Type |
|---|---|---|
| 3V3 | pir-1 VCC | POWER |
| GND | pir-1 GND | GROUND |
| GPIO 27 | pir-1 OUT | DIGITAL |
| GPIO 26 | button-1 SIG | DIGITAL |
| GND | button-1 GND | GROUND |
| 3V3 | led-ring-1 VCC | POWER |
| GND | led-ring-1 GND | GROUND |
| GPIO 4 | led-ring-1 DIN | DIGITAL |
| 3V3 | oled-1 VCC | POWER |
| GND | oled-1 GND | GROUND |
| GPIO 21 | oled-1 SDA | I2C |
| GPIO 22 | oled-1 SCL | I2C |
Code
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_GFX.h>
#include <FastLED.h>
#define PIR_PIN 27
#define BUTTON_PIN 26
#define LED_PIN 4
#define NUM_LEDS 12
#define SDA_PIN 21
#define SCL_PIN 22
Adafruit_SSD1306 display(128, 64, &Wire, -1);
CRGB leds[NUM_LEDS];
bool manualBusy = false;
bool occupied = false;
unsigned long lastMotionMs = 0;
unsigned long lastButtonMs = 0;
const unsigned long occupiedHoldMs = 120000;
void setRing(CRGB color) {
fill_solid(leds, NUM_LEDS, color);
FastLED.show();
}
void drawStatus(const char* status, const char* detail) {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(2);
display.setCursor(0, 6);
display.println(status);
display.setTextSize(1);
display.setCursor(0, 40);
display.println(detail);
display.display();
}
void setup() {
pinMode(PIR_PIN, INPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
Wire.begin(SDA_PIN, SCL_PIN);
FastLED.addLeds<NEOPIXEL, LED_PIN>(leds, NUM_LEDS);
FastLED.setBrightness(80);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
drawStatus("FREE", "Wave nearby or press button");
setRing(CRGB::Green);
}
void loop() {
if (digitalRead(PIR_PIN) == HIGH) {
occupied = true;
lastMotionMs = millis();
}
if (digitalRead(BUTTON_PIN) == LOW && millis() - lastButtonMs > 350) {
manualBusy = !manualBusy;
lastButtonMs = millis();
}
if (occupied && millis() - lastMotionMs > occupiedHoldMs) {
occupied = false;
}
if (manualBusy) {
setRing(CRGB::Red);
drawStatus("BUSY", "Manual focus mode");
} else if (occupied) {
setRing(CRGB::Orange);
drawStatus("HERE", "Motion seen at desk");
} else {
setRing(CRGB::Green);
drawStatus("FREE", "No recent motion");
}
delay(200);
}
// Run this and build other cool things at schematik.ioReady to build this?
Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the ESP32 Desk Presence Busy Light.
Open in Schematik →