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 set focus mode deliberately.
The build is intentionally practical. You wire a common HC-SR501 PIR module to a digital input, use the ESP32 internal pull-up for the button, share the OLED over I²C, and drive the addressable LED ring from one data pin. No Wi-Fi, no phone app, and no account is required.
By the end you have a useful desk sign for shared studios, home offices, or classrooms where people need a clear signal before interrupting you.
What you are building
This guide produces a standalone presence indicator. The firmware has four jobs:
- poll the HC-SR501 PIR sensor and mark the desk as occupied for
occupiedHoldMsmilliseconds after the last detected motion (default 120 seconds), - debounce the tactile button and toggle manual busy mode on each press,
- update the WS2812B LED ring colour: green (free), orange (recently active), red (manual busy),
- update the SSD1306 OLED with a two-line status: a large label (FREE / HERE / BUSY) and a short detail string.
Out of scope: Wi-Fi reporting, MQTT, Home Assistant integration, and multi-room synchronisation. The code is structured to make those additions straightforward once the physical device works reliably.
Upload and calibrate
Flash the starter sketch from Schematik. There are no Wi-Fi credentials to enter. The only runtime constant worth tuning is occupiedHoldMs, which controls how long the desk stays in the HERE (orange) state after the last detected movement:
occupiedHoldMs— default 120000 (2 minutes). Reduce it if the status takes too long to clear after you leave; increase it if it flickers back to FREE during normal desk use.
After flashing, open Serial Monitor at 115200 baud and verify the board starts. Wave a hand near the PIR; the ring should turn orange and the OLED should show HERE. Press the button; the ring should turn red and the OLED should show BUSY. Press again to return to the automatic state.
If the ring flickers or shows wrong colours, lower FastLED.setBrightness() from 80 to 40 to reduce current draw.
Troubleshooting
- PIR triggers constantly with no movement: the HC-SR501 needs a 30–60 second warm-up after power-on before readings are reliable. Wait a minute after boot before testing.
- PIR never triggers: confirm OUT is wired to GPIO 27, not to the VCC or GND rail. The dome side of the sensor should face the area you want to monitor, not the wall.
- Button toggles twice per press: the firmware debounces with a 350 ms guard on
lastButtonMs. If double-triggering persists, increase that value in the code. - OLED blank, no startup text: check SDA on GPIO 21 and SCL on GPIO 22. Most SSD1306 modules use I²C address 0x3C; confirm with an I²C scanner if the display does not respond.
- LED ring shows wrong colour or stays off: confirm GND is shared between the ring and the ESP32. A loose data wire on GPIO 4 is the most common cause of missing colour output.
- All three states show the same colour: FastLED requires the
NEOPIXELtype and correctNUM_LEDScount. Confirm the ring pixel count matchesNUM_LEDS 12in the code, or adjust it to match your ring.
Going further
The three-state model maps naturally onto MQTT: publish the current state string on each transition and subscribe to a "set busy" topic from another device. That opens up integration with calendar apps, meeting room displays, or a simple Home Assistant automation. On the physical side, replacing the LED ring with a larger diffused enclosure — a frosted ping-pong ball, a lamp shade, or a 3D-printed diffuser — makes the colour visible from across a room without being distracting at close range.
Wiring diagram
Components needed
Supplier links, prices, and availability are shown as a guide and may change. 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 <FastLED.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_GFX.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 →