Schematik build

How to Build an ESP32 Desk Presence Busy Light

Motion sensing, manual busy mode, OLED status, and a colour LED ring

Schematik

Published May 27, 2026 · Updated June 12, 2026

ESP32Beginner35 minutes4 components5 assembly steps
Remix this project
Photo of How to Build an ESP32 Desk Presence Busy Light

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:

  1. poll the HC-SR501 PIR sensor and mark the desk as occupied for occupiedHoldMs milliseconds after the last detected motion (default 120 seconds),
  2. debounce the tactile button and toggle manual busy mode on each press,
  3. update the WS2812B LED ring colour: green (free), orange (recently active), red (manual busy),
  4. 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 NEOPIXEL type and correct NUM_LEDS count. Confirm the ring pixel count matches NUM_LEDS 12 in 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

Interactive · read-only

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
HC-SR501 PIR Motion Sensor1Detects recent movement near the desk.
Tactile Push Button1Toggles manual busy mode.
WS2812B LED Ring1Shows free, present, or busy status in colour.
SSD1306 OLED Display1Shows the current desk status as text.

Assembly

5 steps
  1. Wire the shared power rails

    Connect each module VCC to the ESP32 3V3 rail where supported, and connect all grounds together.

  2. Connect the PIR sensor

    Wire PIR OUT to GPIO 27, then place the sensor so it sees the desk rather than the whole room.

  3. 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.

  4. 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.

  5. Upload and tune

    Upload the sketch, check the three states, then adjust occupiedHoldMs if the status clears too quickly or too slowly.

Pin assignments

Board wiring reference
PinConnectionType
3V3pir-1 VCCpower
GNDpir-1 GNDground
GPIO 27pir-1 OUTdigital
GPIO 26button-1 SIGdigital
GNDbutton-1 GNDground
3V3led-ring-1 VCCpower
GNDled-ring-1 GNDground
GPIO 4led-ring-1 DINdigital
3V3oled-1 VCCpower
GNDoled-1 GNDground
GPIO 21oled-1 SDAi2c
GPIO 22oled-1 SCLi2c

Firmware

ESP32
schematik_esp32.inoDeploy to device
#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);
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik